diff --git a/caseworker/f680/templates/f680/case/detail.html b/caseworker/f680/templates/f680/case/detail.html index 2b4895a14f..123a6cccef 100644 --- a/caseworker/f680/templates/f680/case/detail.html +++ b/caseworker/f680/templates/f680/case/detail.html @@ -4,4 +4,13 @@

Case Summary

{% include "f680/case/summary.html" %} + +{% for section_key, section in case.data.application.sections.items %} + {% if section.type == "single" %} + {% include "f680/includes/application_section_single.html" with section=section section_key=section_key %} + {% else %} + {% include "f680/includes/application_section_multiple.html" with section=section section_key=section_key %} + {% endif %} +{% endfor %} + {% endblock %} diff --git a/caseworker/f680/templates/f680/includes/application_section.html b/caseworker/f680/templates/f680/includes/application_section.html new file mode 100644 index 0000000000..c85b3e3d70 --- /dev/null +++ b/caseworker/f680/templates/f680/includes/application_section.html @@ -0,0 +1,3 @@ +

{{section.label}}

+ +{% include "f680/includes/application_section_fields.html" with item=section section_key=section_key %} diff --git a/caseworker/f680/templates/f680/includes/application_section_fields.html b/caseworker/f680/templates/f680/includes/application_section_fields.html new file mode 100644 index 0000000000..9c1e6ab231 --- /dev/null +++ b/caseworker/f680/templates/f680/includes/application_section_fields.html @@ -0,0 +1,10 @@ + + + {% for field in item.fields %} + + + + + {% endfor %} + +
{{field.question}}{{field.answer}}
diff --git a/caseworker/f680/templates/f680/includes/application_section_multiple.html b/caseworker/f680/templates/f680/includes/application_section_multiple.html new file mode 100644 index 0000000000..6ea6273f1d --- /dev/null +++ b/caseworker/f680/templates/f680/includes/application_section_multiple.html @@ -0,0 +1,5 @@ +

{{section.label}}

+ +{% for item in section.items %} + {% include "f680/includes/application_section_fields.html" with item=item section_key=section_key %} +{% endfor %} diff --git a/caseworker/f680/templates/f680/includes/application_section_single.html b/caseworker/f680/templates/f680/includes/application_section_single.html new file mode 100644 index 0000000000..c85b3e3d70 --- /dev/null +++ b/caseworker/f680/templates/f680/includes/application_section_single.html @@ -0,0 +1,3 @@ +

{{section.label}}

+ +{% include "f680/includes/application_section_fields.html" with item=section section_key=section_key %} diff --git a/caseworker/f680/tests/test_views.py b/caseworker/f680/tests/test_views.py index 16db9c16e2..3558c4a1bb 100644 --- a/caseworker/f680/tests/test_views.py +++ b/caseworker/f680/tests/test_views.py @@ -61,7 +61,30 @@ def data_f680_case(f680_case_id, f680_reference_code): "copy_of": None, "countersign_advice": [], "data": { - "application": {"some": "json"}, + "application": { + "sections": { + "general_application_details": { + "label": "General application details", + "type": "single", + "fields": [ + { + "key": "name", + "answer": "some name", + "datatype": "string", + "question": "Name the application", + "raw_answer": "casdc", + }, + { + "key": "is_exceptional_circumstances", + "answer": "No", + "datatype": "boolean", + "question": "Do you have exceptional circumstances that mean you need F680 approval in less than 30 days?", + "raw_answer": False, + }, + ], + }, + } + }, "id": f680_case_id, "organisation": { "id": "1363b104-9669-4c53-8602-8fc3717b07cd", # /PS-IGNORE @@ -97,6 +120,13 @@ def mock_f680_case(f680_case_id, requests_mock, data_f680_case): return requests_mock.get(url=url, json=data_f680_case) +@pytest.fixture +def mock_f680_case_with_submitted_by(f680_case_id, requests_mock, data_f680_case): + data_f680_case["case"]["data"]["submitted_by"] = {"first_name": "foo", "last_name": "bar"} + url = client._build_absolute_uri(f"/cases/{f680_case_id}/") + return requests_mock.get(url=url, json=data_f680_case) + + class TestCaseDetailView: def test_GET_success( @@ -108,6 +138,27 @@ def test_GET_success( assert dict(response.context["case"]) == data_f680_case["case"] soup = BeautifulSoup(response.content, "html.parser") assert f680_reference_code in soup.find("h1").text + assert "General application details" in soup.find("h2").text + table_elems = soup.find_all("table", {"class": "application-section-general_application_details"}) + assert len(table_elems) == 1 + table_text = table_elems[0].text + assert ( + "Do you have exceptional circumstances that mean you need F680 approval in less than 30 days?" in table_text + ) + assert "some name" in table_text + + def test_GET_success_transformed_submitted_by( + self, + authorized_client, + data_queue, + mock_f680_case_with_submitted_by, + f680_case_id, + f680_reference_code, + data_f680_case, + ): + url = reverse("cases:f680:details", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id}) + response = authorized_client.get(url) + assert response.status_code == 200 def test_GET_not_logged_in( self, client, data_queue, mock_f680_case, f680_case_id, f680_reference_code, data_f680_case diff --git a/caseworker/f680/views.py b/caseworker/f680/views.py index 6bba6853cd..26bbde145d 100644 --- a/caseworker/f680/views.py +++ b/caseworker/f680/views.py @@ -20,5 +20,8 @@ def setup(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context_data = super().get_context_data(**kwargs) + submitted_by = self.case["data"]["submitted_by"] + if submitted_by and "first_name" in submitted_by: + self.case["data"]["submitted_by"] = " ".join([submitted_by["first_name"], submitted_by["last_name"]]) context_data["case"] = self.case return context_data diff --git a/conf/exporter.py b/conf/exporter.py index 8fa601a577..3cb89107bf 100644 --- a/conf/exporter.py +++ b/conf/exporter.py @@ -131,3 +131,4 @@ CSP_REPORT_URI = env.tuple("EXPORTER_CSP_REPORT_URI", default=("",)) E2E_WAIT_MULTIPLIER = env.int("E2E_WAIT_MULTIPLIER", default=1) +FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = env.list("FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS", default=[]) diff --git a/exporter/apply_for_a_licence/forms/triage_questions.py b/exporter/apply_for_a_licence/forms/triage_questions.py index 5c8bd08491..c968ec9b7a 100644 --- a/exporter/apply_for_a_licence/forms/triage_questions.py +++ b/exporter/apply_for_a_licence/forms/triage_questions.py @@ -1,3 +1,4 @@ +import rules from django.urls import reverse from django.conf import settings @@ -24,7 +25,7 @@ from django.template.loader import render_to_string -def opening_question(): +def opening_question(request): options = [ Option( key="export_licence", @@ -42,7 +43,7 @@ def opening_question(): "governments and individuals. This includes F680 approval. You should apply for security approval" " before you apply for a licence." ), - disabled=not settings.FEATURE_FLAG_ALLOW_F680, + disabled=not rules.test_rule("can_exporter_use_f680s", request), ), Option( key="transhipment", diff --git a/exporter/apply_for_a_licence/views.py b/exporter/apply_for_a_licence/views.py index fb135059ea..71e1be22fd 100644 --- a/exporter/apply_for_a_licence/views.py +++ b/exporter/apply_for_a_licence/views.py @@ -23,7 +23,7 @@ class LicenceType(LoginRequiredMixin, SingleFormView): def init(self, request, **kwargs): - self.form = opening_question() + self.form = opening_question(request) self.action = validate_opening_question def get_success_url(self): diff --git a/exporter/core/rules.py b/exporter/core/rules.py new file mode 100644 index 0000000000..bb9cd5749c --- /dev/null +++ b/exporter/core/rules.py @@ -0,0 +1,16 @@ +import rules +from django.conf import settings + + +@rules.predicate +def can_exporter_use_f680s(request): + return ( + request.session["organisation"] in settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS + or settings.FEATURE_FLAG_ALLOW_F680 + ) + + +rules.add_rule( + "can_exporter_use_f680s", + can_exporter_use_f680s, +) diff --git a/exporter/core/tests/test_rules.py b/exporter/core/tests/test_rules.py new file mode 100644 index 0000000000..b6df185f4f --- /dev/null +++ b/exporter/core/tests/test_rules.py @@ -0,0 +1,29 @@ +import pytest +import rules + + +@pytest.mark.parametrize( + "user_organisation, allowed_organisations_feature, f680_enabled_feature_flag, expected", + ( + ("12345", ["12345", "98765", "56757"], False, True), + ("99999", ["12345", "98765", "56757"], False, False), + ("", ["12345", "98765", "56757"], False, False), + ("99999", [], False, False), + (None, [], False, False), + ("99999", [], True, True), + ("99999", ["12345", "98765", "56757"], True, True), + ), +) +def test_can_exporter_use_f680s( + rf, client, user_organisation, allowed_organisations_feature, f680_enabled_feature_flag, expected, settings +): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = allowed_organisations_feature + settings.FEATURE_FLAG_ALLOW_F680 = f680_enabled_feature_flag + + request = rf.get("/") + request.session = client.session + session = request.session + session["organisation"] = user_organisation + session.save() + + assert rules.test_rule("can_exporter_use_f680s", request) is expected diff --git a/exporter/f680/application_sections/additional_information/tests/test_views.py b/exporter/f680/application_sections/additional_information/tests/test_views.py index 60fb1eba0f..6df4e991ce 100644 --- a/exporter/f680/application_sections/additional_information/tests/test_views.py +++ b/exporter/f680/application_sections/additional_information/tests/test_views.py @@ -13,6 +13,18 @@ def unset_f680_feature_flag(settings): settings.FEATURE_FLAG_ALLOW_F680 = False +@pytest.fixture() +def set_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = [organisation_pk] + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture() +def unset_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = ["12345"] + settings.FEATURE_FLAG_ALLOW_F680 = False + + @pytest.fixture(autouse=True) def setup(mock_exporter_user_me, settings): settings.FEATURE_FLAG_ALLOW_F680 = True @@ -55,7 +67,21 @@ def mock_f680_application_get(requests_mock, data_f680_case): @pytest.fixture def mock_f680_application_get_existing_data(requests_mock, data_f680_case): data_f680_case["application"] = { - "additional_information": {"answers": {"note": "Some note text"}, "questions": {"note": "Add note"}} + "sections": { + "notes_for_case_officers": { + "type": "single", + "label": "Notes for case officers", + "fields": [ + { + "key": "note", + "answer": "Some note text", + "datatype": "string", + "question": "Add note", + "raw_answer": "Some note text", + } + ], + } + } } application_id = data_f680_case["id"] url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") @@ -100,6 +126,17 @@ def test_GET_success( assert response.status_code == 200 assert isinstance(response.context["form"], NotesForCaseOfficerForm) + def test_GET_success_with_organisation_set( + self, + authorized_client, + mock_f680_application_get, + f680_application_wizard_url, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_application_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], NotesForCaseOfficerForm) + def test_GET_no_feature_flag_forbidden( self, authorized_client, @@ -111,6 +148,17 @@ def test_GET_no_feature_flag_forbidden( assert response.status_code == 200 assert response.context["title"] == "Forbidden" + def test_GET_no_feature_organisation_allowed( + self, + authorized_client, + mock_f680_application_get, + f680_application_wizard_url, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_application_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + def test_POST_approval_type_and_submit_wizard_success( self, post_to_step, goto_step, mock_f680_application_get, mock_patch_f680_application ): @@ -123,7 +171,21 @@ def test_POST_approval_type_and_submit_wizard_success( assert mock_patch_f680_application.last_request.json() == { "application": { "name": "F680 Test 1", - "additional_information": {"answers": {"note": "Some information"}, "questions": {"note": "Add note"}}, + "sections": { + "notes_for_case_officers": { + "label": "Notes for case officers", + "fields": [ + { + "key": "note", + "answer": "Some information", + "raw_answer": "Some information", + "question": "Add note", + "datatype": "string", + } + ], + "type": "single", + } + }, } } diff --git a/exporter/f680/application_sections/additional_information/views.py b/exporter/f680/application_sections/additional_information/views.py index e5e4aa4d7b..c354619afc 100644 --- a/exporter/f680/application_sections/additional_information/views.py +++ b/exporter/f680/application_sections/additional_information/views.py @@ -8,4 +8,5 @@ class NotesForCaseOfficersView(F680ApplicationSectionWizard): form_list = [ (FormSteps.NOTES_FOR_CASEWORKER, NotesForCaseOfficerForm), ] - section = "additional_information" + section = "notes_for_case_officers" + section_label = "Notes for case officers" diff --git a/exporter/f680/application_sections/approval_details/constants.py b/exporter/f680/application_sections/approval_details/constants.py index 8b460f0236..bebc2484c0 100644 --- a/exporter/f680/application_sections/approval_details/constants.py +++ b/exporter/f680/application_sections/approval_details/constants.py @@ -1,2 +1,13 @@ class FormSteps: APPROVAL_TYPE = "APPROVAL_TYPE" + PRODUCT_NAME = "PRODUCT_NAME" + PRODUCT_DESCRIPTION = "PRODUCT_DESCRIPTION" + PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED = "PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED" + PRODUCT_CONTROLLED_UNDER_ITAR = "PRODUCT_CONTROLLED_UNDER_ITAR" + PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS = "PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS" + PRODUCT_INCLUDE_CRYPTOGRAPHY = "PRODUCT_INCLUDE_CRYPTOGRAPHY" + PRODUCT_RATED_UNDER_MTCR = "PRODUCT_RATED_UNDER_MTCR" + PRODUCT_MANPAD = "PRODUCT_MANPAD" + PRODUCT_ELECTRONICMODDATA = "PRODUCT_ELECTRONICMODDATA" + PRODUCT_FUNDING = "PRODUCT_FUNDING" + PRODUCT_USED_BY_UK_ARMED_FORCES = "PRODUCT_USED_BY_UK_ARMED_FORCES" diff --git a/exporter/f680/application_sections/approval_details/forms.py b/exporter/f680/application_sections/approval_details/forms.py index f1f1b0736a..59cb19a1b9 100644 --- a/exporter/f680/application_sections/approval_details/forms.py +++ b/exporter/f680/application_sections/approval_details/forms.py @@ -2,10 +2,17 @@ from django.db.models import TextChoices from django.template.loader import render_to_string +from crispy_forms_gds.choices import Choice from crispy_forms_gds.layout.content import HTML from core.common.forms import BaseForm, TextChoice -from core.forms.layouts import F680ConditionalCheckboxes, F680ConditionalCheckboxesQuestion +from core.forms.layouts import ( + F680ConditionalCheckboxes, + F680ConditionalCheckboxesQuestion, + ConditionalRadios, + ConditionalRadiosQuestion, +) +from core.forms.utils import coerce_str_to_bool class ApprovalTypeForm(BaseForm): @@ -82,3 +89,326 @@ def get_layout_fields(self): render_to_string("f680/forms/help_with_approval_type.html"), ), ) + + +class ProductNameForm(BaseForm): + class Layout: + TITLE = "Give the item a descriptive name" + TITLE_AS_LABEL_FOR = "product_name" + SUBTITLE = render_to_string("f680/forms/subtitle_product_name.html") + SUBMIT_BUTTON_TEXT = "Save and continue" + + product_name = forms.CharField( + label=Layout.TITLE, + help_text="Where possible include the make, model and type of the item", + ) + + def get_layout_fields(self): + return ("product_name",) + + +class ProductDescription(BaseForm): + class Layout: + TITLE = "Describe the item" + TITLE_AS_LABEL_FOR = "product_description" + SUBTITLE = render_to_string("f680/forms/subtitle_product_description.html") + SUBMIT_BUTTON_TEXT = "Save and continue" + + product_description = forms.CharField( + label=Layout.TITLE, + help_text="Where possible include the make, model and type of the item", + widget=forms.Textarea(attrs={"rows": 5}), + ) + + def get_layout_fields(self): + return ( + "product_description", + HTML.details( + "Help with incorporating an item", + render_to_string("f680/forms/help_product_description.html"), + ), + ) + + +class ProductForeignTechOrSharedInformation(BaseForm): + class Layout: + TITLE = "Will any foreign technology or information be shared with the item?" + TITLE_AS_LABEL_FOR = "is_foreign_tech_or_information_shared" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_foreign_tech_or_information_shared = forms.TypedChoiceField( + choices=( + (True, "Yes"), + (False, "No"), + ), + label=Layout.TITLE, + widget=forms.RadioSelect, + coerce=coerce_str_to_bool, + ) + + def get_layout_fields(self): + return ("is_foreign_tech_or_information_shared",) + + +class ProductControlledUnderItar(BaseForm): + class Layout: + TITLE = ( + "Is the technology or information controlled under the US International Traffic in Arms Regulations (ITAR)?" + ) + TITLE_AS_LABEL_FOR = "is_controlled_under_itar" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_controlled_under_itar = forms.TypedChoiceField( + choices=( + (True, "Yes, it's controlled under ITAR"), + (False, "No"), + ), + help_text="We need to know about any items classified as Defence Articles or Technical Data.", + label=Layout.TITLE, + widget=forms.RadioSelect, + coerce=coerce_str_to_bool, + ) + + controlled_info = forms.CharField( + widget=forms.Textarea(attrs={"rows": 5}), + label=( + "Explain how the technology or information is controlled." + "Include countries classification levels and reference numbers." + " You can upload supporting documents later in your application" + ), + required=False, + ) + + def get_layout_fields(self): + return ( + ConditionalRadios( + "is_controlled_under_itar", + ConditionalRadiosQuestion("Yes, it's controlled under ITAR", "controlled_info"), + "No", + ), + HTML.details( + "Help with ITAR", + render_to_string("f680/forms/help_ITAR.html"), + ), + ) + + +class ProductControlledUnderItarDetails(BaseForm): + class Layout: + TITLE = "Tell us about the technology or information controlled under ITAR" + SUBMIT_BUTTON_TEXT = "Save and continue" + + controlled_information = forms.CharField( + label="What is the ITAR controlled technology or information?", + widget=forms.Textarea(attrs={"rows": 5}), + ) + + itar_reference_number = forms.CharField( + label="ITAR reference number", + help_text="You can find this on the licence, agreement or authorisation you received from the US", + ) + + usml_categories = forms.CharField( + label="What are the United States Munitions List (USML) categories listed on your ITAR approval?", + help_text="You can find this on the licence, agreement or authorisation you received from the US", + ) + + itar_approval_scope = forms.CharField( + label="Describe the scope of your ITAR approval", + help_text="You can find this on the licence, agreement or authorisation you received from the US", + widget=forms.Textarea(attrs={"rows": 5}), + ) + + expected_time_in_possession = forms.CharField( + label=( + "How long do you expect the technology or information that is controlled under the US ITAR " + "to be in your possession?" + ), + help_text="For example, 10 years", + ) + + def get_layout_fields(self): + return ( + "controlled_information", + "itar_reference_number", + "usml_categories", + "itar_approval_scope", + "expected_time_in_possession", + ) + + +class ProductIncludeCryptography(BaseForm): + class Layout: + TITLE = "Does the item include cryptography or other information security features?" + TITLE_AS_LABEL_FOR = "is_including_cryptography_or_security_features" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_including_cryptography_or_security_features = forms.TypedChoiceField( + choices=( + (True, "Yes"), + (False, "No"), + ), + help_text="We need to know about any items classified as Defence Articles or Technical Data.", + label=Layout.TITLE, + widget=forms.RadioSelect, + coerce=coerce_str_to_bool, + ) + + cryptography_or_security_feature_info = forms.CharField( + widget=forms.Textarea(attrs={"rows": 5}), + label="Provide full details", + required=False, + ) + + def get_layout_fields(self): + return ( + ConditionalRadios( + "is_including_cryptography_or_security_features", + ConditionalRadiosQuestion("Yes", "cryptography_or_security_feature_info"), + "No", + ), + HTML.details( + "Help with security features", + render_to_string("f680/forms/help_security_features.html"), + ), + ) + + +class ProductRatedUnderMTCR(BaseForm): + class Layout: + TITLE = "Do you believe the item is rated under the Missile Technology Control Regime (MTCR)" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_item_rated_under_mctr = forms.ChoiceField( + choices=( + Choice("mtcr_1", "Yes, the product is MTCR Category 1"), + Choice("mtcr_2", "Yes, the product is MTCR Category 2"), + Choice("supports_mtcr_1", "No, but the item supports a MTCR Category 1 item"), + Choice("supports_mtcr_2", "No, but the item supports a MTCR Category 2 item"), + Choice("no", "No", divider="Or"), + Choice("dont_know", "Don't know"), + ), + widget=forms.RadioSelect, + label="Do you believe the item is rated under the Missile Technology Control Regime (MTCR)", + ) + + def get_layout_fields(self): + return ( + "is_item_rated_under_mctr", + HTML.details( + "Help with MTCR categories", + render_to_string("f680/forms/help_mctr_categories.html"), + ), + ) + + +class ProductMANPADs(BaseForm): + class Layout: + TITLE = "Do you believe the item is a man-portable air defence system (MANPAD)?" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_item_manpad = forms.ChoiceField( + choices=( + Choice("yes", "Yes, the product is a MANPAD"), + Choice("no", "No, the product is not a MANPAD", divider="Or"), + Choice("dont_know", "Don't know"), + ), + widget=forms.RadioSelect, + label="Do you believe the item is a man-portable air defence system (MANPAD)?", + ) + + def get_layout_fields(self): + return ( + "is_item_manpad", + HTML.details( + "Help with MANPADs", + render_to_string("f680/forms/help_manpads.html"), + ), + ) + + +class ProductElectronicMODData(BaseForm): + class Layout: + TITLE = "Will any electronic warfare data owned by the Ministry of Defence (MOD) be shared with the item?" + TITLE_AS_LABEL_FOR = "is_mod_electronic_data_shared" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_mod_electronic_data_shared = forms.ChoiceField( + choices=( + Choice("yes", "Yes"), + Choice("no", "No"), + ), + widget=forms.RadioSelect, + label=Layout.TITLE, + ) + + def get_layout_fields(self): + return ( + ConditionalRadios( + "is_mod_electronic_data_shared", + ConditionalRadiosQuestion( + "Yes", + HTML.p( + "You need to complete part A off the MOD EW Data Release Capture Form and attach " + "it to the application in the supporting documents section" + ), + ), + "No", + ), + HTML.details( + "Help with electronic warfare data", + render_to_string("f680/forms/help_electronic_warfare_data.html"), + ), + ) + + +class ProductFunding(BaseForm): + class Layout: + TITLE = "Who is funding the item?" + TITLE_AS_LABEL_FOR = "funding_source" + SUBMIT_BUTTON_TEXT = "Save and continue" + + funding_source = forms.ChoiceField( + choices=( + Choice("mod", "MOD"), + Choice("part_mod", "Part MOD"), + Choice("private_venture", "Private venture"), + ), + widget=forms.RadioSelect, + label="Who is funding the item?", + ) + + def get_layout_fields(self): + return ("funding_source",) + + +class ProductUsedByUKArmedForces(BaseForm): + class Layout: + TITLE = "Will the item be used by the UK Armed Forces?" + TITLE_AS_LABEL_FOR = "is_used_by_uk_armed_forces" + SUBMIT_BUTTON_TEXT = "Save and continue" + + is_used_by_uk_armed_forces = forms.TypedChoiceField( + choices=( + (True, "Yes"), + (False, "No"), + ), + label=Layout.TITLE, + widget=forms.RadioSelect, + coerce=coerce_str_to_bool, + ) + + used_by_uk_armed_forces_info = forms.CharField( + widget=forms.Textarea(attrs={"rows": 5}), + label="Explain how it will be used", + required=False, + ) + + def get_layout_fields(self): + return ( + ConditionalRadios( + "is_used_by_uk_armed_forces", + ConditionalRadiosQuestion("Yes", "used_by_uk_armed_forces_info"), + "No", + ), + ) diff --git a/exporter/f680/application_sections/approval_details/tests/test_views.py b/exporter/f680/application_sections/approval_details/tests/test_views.py index 4197f7f48b..73596b3b0f 100644 --- a/exporter/f680/application_sections/approval_details/tests/test_views.py +++ b/exporter/f680/application_sections/approval_details/tests/test_views.py @@ -4,7 +4,7 @@ from core import client -from ..forms import ApprovalTypeForm +from .. import forms from ..constants import FormSteps @@ -13,6 +13,18 @@ def unset_f680_feature_flag(settings): settings.FEATURE_FLAG_ALLOW_F680 = False +@pytest.fixture() +def set_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = [organisation_pk] + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture() +def unset_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = ["12345"] + settings.FEATURE_FLAG_ALLOW_F680 = False + + @pytest.fixture(autouse=True) def setup(mock_exporter_user_me, settings): settings.FEATURE_FLAG_ALLOW_F680 = True @@ -24,7 +36,7 @@ def missing_application_id(): @pytest.fixture -def missing_f680_application_wizard_url(missing_application_id): +def missing_f680_approval_type_wizard_url(missing_application_id): return reverse( "f680:approval_details:type_wizard", kwargs={"pk": missing_application_id}, @@ -32,13 +44,29 @@ def missing_f680_application_wizard_url(missing_application_id): @pytest.fixture -def f680_application_wizard_url(data_f680_case): +def f680_approval_type_wizard_url(data_f680_case): return reverse( "f680:approval_details:type_wizard", kwargs={"pk": data_f680_case["id"]}, ) +@pytest.fixture +def missing_f680_product_wizard_url(missing_application_id): + return reverse( + "f680:approval_details:product_wizard", + kwargs={"pk": missing_application_id}, + ) + + +@pytest.fixture +def f680_product_wizard_url(data_f680_case): + return reverse( + "f680:approval_details:product_wizard", + kwargs={"pk": data_f680_case["id"]}, + ) + + @pytest.fixture def mock_f680_application_get_404(requests_mock, missing_application_id): url = client._build_absolute_uri(f"/exporter/f680/application/{missing_application_id}/") @@ -53,48 +81,48 @@ def mock_f680_application_get(requests_mock, data_f680_case): @pytest.fixture -def mock_f680_application_get_existing_data(requests_mock, data_f680_case): - data_f680_case["application"] = { - "approval_details": { - "answers": { - "approval_choices": [ - "initial_discussion_or_promoting", - "demonstration_in_uk", - "demonstration_overseas", - "training", - "through_life_support", - "supply", - ], - "demonstration_in_uk": "Test text 1", - "demonstration_overseas": "Test text 2", - }, - "questions": { - "approval_choices": None, - "demonstration_in_uk": "Explain what you are demonstrating and why", - "demonstration_overseas": "Explain what you are demonstrating and why", - }, - }, - } +def mock_patch_f680_application(requests_mock, data_f680_case): application_id = data_f680_case["id"] url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") - return requests_mock.get(url=url, json=data_f680_case) + return requests_mock.patch(url=url, json=data_f680_case) @pytest.fixture -def mock_patch_f680_application(requests_mock, data_f680_case): - application_id = data_f680_case["id"] - url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") - return requests_mock.patch(url=url, json=data_f680_case) +def post_to_approval_type_step(post_to_step_factory, f680_approval_type_wizard_url): + return post_to_step_factory(f680_approval_type_wizard_url) + + +@pytest.fixture +def goto_approval_type_step(goto_step_factory, f680_approval_type_wizard_url): + return goto_step_factory(f680_approval_type_wizard_url) + + +@pytest.fixture +def post_to_product_step(post_to_step_factory, f680_product_wizard_url): + return post_to_step_factory(f680_product_wizard_url) @pytest.fixture -def post_to_step(post_to_step_factory, f680_application_wizard_url): - return post_to_step_factory(f680_application_wizard_url) +def goto_product_step(goto_step_factory, f680_product_wizard_url): + return goto_step_factory(f680_product_wizard_url) @pytest.fixture -def goto_step(goto_step_factory, f680_application_wizard_url): - return goto_step_factory(f680_application_wizard_url) +def force_foreign_tech(goto_product_step, post_to_product_step): + goto_product_step(FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED) + post_to_product_step( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + {"is_foreign_tech_or_information_shared": True}, + ) + + +@pytest.fixture +def force_product_under_itar(goto_product_step, post_to_product_step): + goto_product_step(FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR) + post_to_product_step( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + {"is_controlled_under_itar": True}, + ) class TestApprovalDetailsView: @@ -102,37 +130,63 @@ class TestApprovalDetailsView: def test_GET_no_application_404( self, authorized_client, - missing_f680_application_wizard_url, + missing_f680_approval_type_wizard_url, mock_f680_application_get_404, ): - response = authorized_client.get(missing_f680_application_wizard_url) + response = authorized_client.get(missing_f680_approval_type_wizard_url) assert response.status_code == 404 def test_GET_success( self, authorized_client, mock_f680_application_get, - f680_application_wizard_url, + f680_approval_type_wizard_url, ): - response = authorized_client.get(f680_application_wizard_url) + response = authorized_client.get(f680_approval_type_wizard_url) assert response.status_code == 200 - assert isinstance(response.context["form"], ApprovalTypeForm) + assert isinstance(response.context["form"], forms.ApprovalTypeForm) + + def test_GET_success_with_organisation_set( + self, + authorized_client, + mock_f680_application_get, + f680_approval_type_wizard_url, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_approval_type_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.ApprovalTypeForm) def test_GET_no_feature_flag_forbidden( self, authorized_client, mock_f680_application_get, - f680_application_wizard_url, + f680_approval_type_wizard_url, unset_f680_feature_flag, ): - response = authorized_client.get(f680_application_wizard_url) + response = authorized_client.get(f680_approval_type_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + + def test_GET_no_feature_organisation_allowed( + self, + authorized_client, + mock_f680_application_get, + f680_approval_type_wizard_url, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_approval_type_wizard_url) assert response.status_code == 200 assert response.context["title"] == "Forbidden" def test_POST_approval_type_and_submit_wizard_success( - self, post_to_step, goto_step, mock_f680_application_get, mock_patch_f680_application + self, + post_to_approval_type_step, + goto_approval_type_step, + mock_f680_application_get, + mock_patch_f680_application, ): - response = post_to_step( + response = post_to_approval_type_step( FormSteps.APPROVAL_TYPE, {"approval_choices": ["training", "supply"]}, ) @@ -141,31 +195,53 @@ def test_POST_approval_type_and_submit_wizard_success( assert mock_patch_f680_application.last_request.json() == { "application": { "name": "F680 Test 1", - "approval_details": { - "answers": { - "approval_choices": ["training", "supply"], - "demonstration_in_uk": "", - "demonstration_overseas": "", - "approval_details_text": "", - }, - "questions": { - "approval_choices": "Select the types of approvals you need", - "demonstration_in_uk": "Explain what you are demonstrating and why", - "demonstration_overseas": "Explain what you are demonstrating and why", - "approval_details_text": "Provide details about what you're seeking approval to do", - }, + "sections": { + "approval_type": { + "label": "Approval type", + "fields": [ + { + "key": "approval_choices", + "answer": ["Training", "Supply"], + "raw_answer": ["training", "supply"], + "question": "Select the types of approvals you need", + "datatype": "list", + }, + { + "key": "demonstration_in_uk", + "answer": "", + "raw_answer": "", + "question": "Explain what you are demonstrating and why", + "datatype": "string", + }, + { + "key": "demonstration_overseas", + "answer": "", + "raw_answer": "", + "question": "Explain what you are demonstrating and why", + "datatype": "string", + }, + { + "key": "approval_details_text", + "answer": "", + "raw_answer": "", + "question": "Provide details about what you're seeking approval to do", + "datatype": "string", + }, + ], + "type": "single", + } }, } } def test_POST_to_step_validation_error( self, - post_to_step, - goto_step, + post_to_approval_type_step, + goto_approval_type_step, mock_f680_application_get, ): - goto_step(FormSteps.APPROVAL_TYPE) - response = post_to_step( + goto_approval_type_step(FormSteps.APPROVAL_TYPE) + response = post_to_approval_type_step( FormSteps.APPROVAL_TYPE, {}, ) @@ -176,11 +252,11 @@ def test_GET_with_existing_data_success( self, authorized_client, mock_f680_application_get_existing_data, - f680_application_wizard_url, + f680_approval_type_wizard_url, ): - response = authorized_client.get(f680_application_wizard_url) + response = authorized_client.get(f680_approval_type_wizard_url) assert response.status_code == 200 - assert isinstance(response.context["form"], ApprovalTypeForm) + assert isinstance(response.context["form"], forms.ApprovalTypeForm) assert response.context["form"]["approval_choices"].initial == [ "initial_discussion_or_promoting", "demonstration_in_uk", @@ -189,5 +265,532 @@ def test_GET_with_existing_data_success( "through_life_support", "supply", ] - assert response.context["form"]["demonstration_in_uk"].initial == "Test text 1" - assert response.context["form"]["demonstration_overseas"].initial == "Test text 2" + assert response.context["form"]["demonstration_in_uk"].initial == "some UK demonstration reason" + assert response.context["form"]["demonstration_overseas"].initial == "some overseas demonstration reason" + assert response.context["form"]["approval_details_text"].initial == "some details" + + +class TestProductInformationViews: + + def test_GET_no_application_404( + self, + authorized_client, + missing_f680_product_wizard_url, + mock_f680_application_get_404, + ): + response = authorized_client.get(missing_f680_product_wizard_url) + assert response.status_code == 404 + + def test_GET_success( + self, + authorized_client, + mock_f680_application_get, + f680_product_wizard_url, + ): + response = authorized_client.get(f680_product_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.ProductNameForm) + + def test_GET_success_with_organisation_set( + self, + authorized_client, + mock_f680_application_get, + f680_product_wizard_url, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_product_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.ProductNameForm) + + def test_GET_no_feature_flag_forbidden( + self, + authorized_client, + mock_f680_application_get, + f680_product_wizard_url, + unset_f680_feature_flag, + ): + response = authorized_client.get(f680_product_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + + def test_GET_no_feature_organisation_allowed( + self, + authorized_client, + mock_f680_application_get, + f680_product_wizard_url, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_product_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + + @pytest.mark.parametrize( + "step, data, expected_next_form", + ( + (FormSteps.PRODUCT_NAME, {"product_name": "Test Name"}, forms.ProductDescription), + ( + FormSteps.PRODUCT_DESCRIPTION, + {"product_description": "Does a thing"}, + forms.ProductForeignTechOrSharedInformation, + ), + ( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + {"is_foreign_tech_or_information_shared": True}, + forms.ProductControlledUnderItar, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + {"is_controlled_under_itar": True, "controlled_info": ""}, + forms.ProductControlledUnderItarDetails, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS, + { + "controlled_information": "secret stuff", + "itar_reference_number": "123456", + "usml_categories": "none", + "itar_approval_scope": "no scope", + "expected_time_in_possession": "10 years", + }, + forms.ProductIncludeCryptography, + ), + ( + FormSteps.PRODUCT_INCLUDE_CRYPTOGRAPHY, + {"is_including_cryptography_or_security_features": True}, + forms.ProductRatedUnderMTCR, + ), + ( + FormSteps.PRODUCT_RATED_UNDER_MTCR, + {"is_item_rated_under_mctr": "mtcr_1"}, + forms.ProductMANPADs, + ), + ( + FormSteps.PRODUCT_MANPAD, + {"is_item_manpad": "no"}, + forms.ProductElectronicMODData, + ), + ( + FormSteps.PRODUCT_ELECTRONICMODDATA, + {"is_mod_electronic_data_shared": "no"}, + forms.ProductFunding, + ), + ( + FormSteps.PRODUCT_FUNDING, + {"funding_source": "private_venture"}, + forms.ProductUsedByUKArmedForces, + ), + ), + ) + def test_POST_to_step_success( + self, + step, + data, + expected_next_form, + post_to_product_step, + goto_product_step, + mock_f680_application_get, + force_foreign_tech, + force_product_under_itar, + ): + goto_product_step(step) + response = post_to_product_step( + step, + data, + ) + assert response.status_code == 200 + assert isinstance(response.context["form"], expected_next_form) + + @pytest.mark.parametrize( + "step, data, expected_errors", + ( + (FormSteps.PRODUCT_NAME, {"product_name": ""}, {"product_name": ["This field is required."]}), + ( + FormSteps.PRODUCT_DESCRIPTION, + {"product_description": ""}, + {"product_description": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + {}, + {"is_foreign_tech_or_information_shared": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_INCLUDE_CRYPTOGRAPHY, + {}, + {"is_including_cryptography_or_security_features": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_RATED_UNDER_MTCR, + {"is_item_rated_under_mctr": ""}, + {"is_item_rated_under_mctr": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_MANPAD, + {"is_item_manpad": ""}, + {"is_item_manpad": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_ELECTRONICMODDATA, + {"is_mod_electronic_data_shared": ""}, + {"is_mod_electronic_data_shared": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_FUNDING, + {"funding_source": ""}, + {"funding_source": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + {}, + {"is_controlled_under_itar": ["This field is required."]}, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS, + { + "controlled_information": "", + "itar_reference_number": "", + "usml_categories": "", + "itar_approval_scope": "", + "expected_time_in_possession": "", + }, + { + "controlled_information": ["This field is required."], + "itar_reference_number": ["This field is required."], + "usml_categories": ["This field is required."], + "itar_approval_scope": ["This field is required."], + "expected_time_in_possession": ["This field is required."], + }, + ), + ), + ) + def test_POST_to_step_validation_error( + self, + step, + data, + expected_errors, + post_to_product_step, + goto_product_step, + mock_f680_application_get, + force_foreign_tech, + force_product_under_itar, + ): + goto_product_step(step) + response = post_to_product_step( + step, + data, + ) + assert response.status_code == 200 + for field_name, error in expected_errors.items(): + assert response.context["form"][field_name].errors == error + + def test_POST_submit_wizard_success( + self, + post_to_product_step, + goto_product_step, + mock_f680_application_get, + mock_patch_f680_application, + force_foreign_tech, + force_product_under_itar, + ): + response = post_to_product_step( + FormSteps.PRODUCT_NAME, + {"product_name": "Test Name"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_DESCRIPTION, + {"product_description": "Does a thing"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + {"is_foreign_tech_or_information_shared": True}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + {"is_controlled_under_itar": True, "controlled_info": ""}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS, + { + "controlled_information": "secret stuff", + "itar_reference_number": "123456", + "usml_categories": "none", + "itar_approval_scope": "no scope", + "expected_time_in_possession": "10 years", + }, + ) + response = post_to_product_step( + FormSteps.PRODUCT_INCLUDE_CRYPTOGRAPHY, + {"is_including_cryptography_or_security_features": True}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_RATED_UNDER_MTCR, + {"is_item_rated_under_mctr": "mtcr_1"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_MANPAD, + {"is_item_manpad": "no"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_ELECTRONICMODDATA, + {"is_mod_electronic_data_shared": "no"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_FUNDING, + {"funding_source": "mod"}, + ) + response = post_to_product_step( + FormSteps.PRODUCT_USED_BY_UK_ARMED_FORCES, + {"is_used_by_uk_armed_forces": True}, + ) + + assert response.status_code == 302 + assert mock_patch_f680_application.called_once + assert mock_patch_f680_application.last_request.json() == { + "application": { + "name": "F680 Test 1", + "sections": { + "product_information": { + "label": "Product information", + "fields": [ + { + "key": "product_name", + "answer": "Test Name", + "raw_answer": "Test Name", + "question": "Give the item a descriptive name", + "datatype": "string", + }, + { + "key": "product_description", + "answer": "Does a thing", + "raw_answer": "Does a thing", + "question": "Describe the item", + "datatype": "string", + }, + { + "key": "is_foreign_tech_or_information_shared", + "answer": "Yes", + "raw_answer": True, + "question": "Will any foreign technology or information be shared with the item?", + "datatype": "boolean", + }, + { + "key": "is_controlled_under_itar", + "answer": "Yes, it's controlled under ITAR", + "raw_answer": True, + "question": "Is the technology or information controlled under the US International Traffic in Arms Regulations (ITAR)?", + "datatype": "boolean", + }, + { + "key": "controlled_info", + "answer": "", + "raw_answer": "", + "question": "Explain how the technology or information is controlled.Include countries classification levels and reference numbers. You can upload supporting documents later in your application", + "datatype": "string", + }, + { + "key": "controlled_information", + "answer": "secret stuff", + "raw_answer": "secret stuff", + "question": "What is the ITAR controlled technology or information?", + "datatype": "string", + }, + { + "key": "itar_reference_number", + "answer": "123456", + "raw_answer": "123456", + "question": "ITAR reference number", + "datatype": "string", + }, + { + "key": "usml_categories", + "answer": "none", + "raw_answer": "none", + "question": "What are the United States Munitions List (USML) categories listed on your ITAR approval?", + "datatype": "string", + }, + { + "key": "itar_approval_scope", + "answer": "no scope", + "raw_answer": "no scope", + "question": "Describe the scope of your ITAR approval", + "datatype": "string", + }, + { + "key": "expected_time_in_possession", + "answer": "10 years", + "raw_answer": "10 years", + "question": "How long do you expect the technology or information that is controlled under the US ITAR to be in your possession?", + "datatype": "string", + }, + { + "key": "is_including_cryptography_or_security_features", + "answer": "Yes", + "raw_answer": True, + "question": "Does the item include cryptography or other information security features?", + "datatype": "boolean", + }, + { + "key": "cryptography_or_security_feature_info", + "answer": "", + "raw_answer": "", + "question": "Provide full details", + "datatype": "string", + }, + { + "key": "is_item_rated_under_mctr", + "answer": "Yes, the product is MTCR Category 1", + "raw_answer": "mtcr_1", + "question": "Do you believe the item is rated under the Missile Technology Control Regime (MTCR)", + "datatype": "string", + }, + { + "key": "is_item_manpad", + "answer": "No, the product is not a MANPAD", + "raw_answer": "no", + "question": "Do you believe the item is a man-portable air defence system (MANPAD)?", + "datatype": "string", + }, + { + "key": "is_mod_electronic_data_shared", + "answer": "No", + "raw_answer": "no", + "question": "Will any electronic warfare data owned by the Ministry of Defence (MOD) be shared with the item?", + "datatype": "string", + }, + { + "key": "funding_source", + "answer": "MOD", + "raw_answer": "mod", + "question": "Who is funding the item?", + "datatype": "string", + }, + { + "key": "is_used_by_uk_armed_forces", + "answer": "Yes", + "raw_answer": True, + "question": "Will the item be used by the UK Armed Forces?", + "datatype": "boolean", + }, + { + "key": "used_by_uk_armed_forces_info", + "answer": "", + "raw_answer": "", + "question": "Explain how it will be used", + "datatype": "string", + }, + ], + "type": "single", + } + }, + } + } + + @pytest.mark.parametrize( + "step, expected_form, expected_initial", + ( + (FormSteps.PRODUCT_NAME, forms.ProductNameForm, {"product_name": "Test Info"}), + ( + FormSteps.PRODUCT_DESCRIPTION, + forms.ProductDescription, + {"product_description": "It does things"}, + ), + ( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + forms.ProductForeignTechOrSharedInformation, + {"is_foreign_tech_or_information_shared": True}, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + forms.ProductControlledUnderItar, + {"is_controlled_under_itar": True}, + ), + ( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS, + forms.ProductControlledUnderItarDetails, + { + "controlled_information": "Some info", + "itar_reference_number": "123456", + "usml_categories": "cat 1", + "itar_approval_scope": "no scope", + "expected_time_in_possession": "10 years", + }, + ), + ( + FormSteps.PRODUCT_INCLUDE_CRYPTOGRAPHY, + forms.ProductIncludeCryptography, + {"is_including_cryptography_or_security_features": True}, + ), + ( + FormSteps.PRODUCT_RATED_UNDER_MTCR, + forms.ProductRatedUnderMTCR, + {"is_item_rated_under_mctr": "mtcr_1"}, + ), + ( + FormSteps.PRODUCT_MANPAD, + forms.ProductMANPADs, + {"is_item_manpad": "no"}, + ), + ( + FormSteps.PRODUCT_ELECTRONICMODDATA, + forms.ProductElectronicMODData, + {"is_mod_electronic_data_shared": "no"}, + ), + ( + FormSteps.PRODUCT_FUNDING, + forms.ProductFunding, + {"funding_source": "mod"}, + ), + ( + FormSteps.PRODUCT_USED_BY_UK_ARMED_FORCES, + forms.ProductUsedByUKArmedForces, + {"is_used_by_uk_armed_forces": False}, + ), + ), + ) + def test_GET_with_existing_data_success( + self, + step, + expected_form, + post_to_product_step, + goto_product_step, + expected_initial, + mock_f680_application_get_existing_data, + force_foreign_tech, + force_product_under_itar, + ): + response = goto_product_step(step) + assert response.status_code == 200 + assert isinstance(response.context["form"], expected_form) + for key, expected_value in expected_initial.items(): + assert response.context["form"][key].initial == expected_value + + def test_is_foreign_tech_or_information_shared_false_displays_correct_form( + self, + post_to_product_step, + goto_product_step, + mock_f680_application_get, + ): + + goto_product_step(FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED) + response = post_to_product_step( + FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, + {"is_foreign_tech_or_information_shared": False}, + ) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.ProductIncludeCryptography) + + def test_is_controlled_under_itar_false_displays_correct_form( + self, + post_to_product_step, + goto_product_step, + mock_f680_application_get, + force_foreign_tech, + ): + + goto_product_step(FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR) + response = post_to_product_step( + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, + {"is_controlled_under_itar": False}, + ) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.ProductIncludeCryptography) diff --git a/exporter/f680/application_sections/approval_details/urls.py b/exporter/f680/application_sections/approval_details/urls.py index 32351c694b..43d02f10b2 100644 --- a/exporter/f680/application_sections/approval_details/urls.py +++ b/exporter/f680/application_sections/approval_details/urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path("type/", views.ApprovalTypeView.as_view(), name="type_wizard"), + path("product/", views.ProductInformationView.as_view(), name="product_wizard"), ] diff --git a/exporter/f680/application_sections/approval_details/views.py b/exporter/f680/application_sections/approval_details/views.py index bfb4dfabc6..ba67289608 100644 --- a/exporter/f680/application_sections/approval_details/views.py +++ b/exporter/f680/application_sections/approval_details/views.py @@ -1,10 +1,58 @@ from exporter.f680.application_sections.views import F680ApplicationSectionWizard + from .constants import FormSteps -from .forms import ApprovalTypeForm + +from .forms import ( + ApprovalTypeForm, + ProductNameForm, + ProductDescription, + ProductForeignTechOrSharedInformation, + ProductControlledUnderItar, + ProductControlledUnderItarDetails, + ProductIncludeCryptography, + ProductRatedUnderMTCR, + ProductMANPADs, + ProductElectronicMODData, + ProductFunding, + ProductUsedByUKArmedForces, +) class ApprovalTypeView(F680ApplicationSectionWizard): form_list = [ (FormSteps.APPROVAL_TYPE, ApprovalTypeForm), ] - section = "approval_details" + section = "approval_type" + section_label = "Approval type" + + +def is_foreign_tech_or_information_shared(wizard): + cleaned_data = wizard.get_cleaned_data_for_step(FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED) or {} + return cleaned_data.get("is_foreign_tech_or_information_shared", False) + + +def is_controlled_under_itar(wizard): + cleaned_data = wizard.get_cleaned_data_for_step(FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR) or {} + return cleaned_data.get("is_controlled_under_itar", False) + + +class ProductInformationView(F680ApplicationSectionWizard): + form_list = [ + (FormSteps.PRODUCT_NAME, ProductNameForm), + (FormSteps.PRODUCT_DESCRIPTION, ProductDescription), + (FormSteps.PRODUCT_FOREIGN_TECHNOLOGY_OR_INFORMATION_SHARED, ProductForeignTechOrSharedInformation), + (FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR, ProductControlledUnderItar), + (FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS, ProductControlledUnderItarDetails), + (FormSteps.PRODUCT_INCLUDE_CRYPTOGRAPHY, ProductIncludeCryptography), + (FormSteps.PRODUCT_RATED_UNDER_MTCR, ProductRatedUnderMTCR), + (FormSteps.PRODUCT_MANPAD, ProductMANPADs), + (FormSteps.PRODUCT_ELECTRONICMODDATA, ProductElectronicMODData), + (FormSteps.PRODUCT_FUNDING, ProductFunding), + (FormSteps.PRODUCT_USED_BY_UK_ARMED_FORCES, ProductUsedByUKArmedForces), + ] + condition_dict = { + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR: is_foreign_tech_or_information_shared, + FormSteps.PRODUCT_CONTROLLED_UNDER_ITAR_DETAILS: is_controlled_under_itar, + } + section = "product_information" + section_label = "Product information" diff --git a/exporter/f680/application_sections/conftest.py b/exporter/f680/application_sections/conftest.py new file mode 100644 index 0000000000..5c9ce04f77 --- /dev/null +++ b/exporter/f680/application_sections/conftest.py @@ -0,0 +1,249 @@ +import pytest + +from core import client + + +@pytest.fixture() +def unset_f680_feature_flag(settings): + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture(autouse=True) +def setup(mock_exporter_user_me, settings): + settings.FEATURE_FLAG_ALLOW_F680 = True + + +@pytest.fixture +def missing_application_id(): + return "6bb0828c-1520-4624-b729-7f3e6e5b9f5d" + + +@pytest.fixture +def mock_f680_application_get_404(requests_mock, missing_application_id): + url = client._build_absolute_uri(f"/exporter/f680/application/{missing_application_id}/") + return requests_mock.get(url=url, json={}, status_code=404) + + +@pytest.fixture +def mock_f680_application_get(requests_mock, data_f680_case): + application_id = data_f680_case["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case) + + +@pytest.fixture +def mock_patch_f680_application(requests_mock, data_f680_case): + application_id = data_f680_case["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.patch(url=url, json=data_f680_case) + + +@pytest.fixture +def data_f680_case(data_organisation): + return { + "id": "6cf7b401-62dc-4577-ad1d-4282f2aabc96", + "application": {"name": "F680 Test 1"}, + "reference_code": None, + "organisation": { + "id": "3913ff20-5a2b-468a-bf5d-427228459b06", + "name": "Archway Communications", + "type": "commercial", + "status": "active", + }, + "submitted_at": None, + "submitted_by": None, + } + + +@pytest.fixture +def mock_f680_application_get_existing_data(requests_mock, data_f680_case): + data_f680_case["application"] = { + "sections": { + "approval_type": { + "type": "single", + "label": "Approval type", + "fields": [ + { + "key": "approval_choices", + "answer": [ + "Initial discussions or promoting products", + "Demonstration in the United Kingdom to overseas customers", + "Demonstration overseas", + "Training", + "Through life support", + "Supply", + ], + "datatype": "list", + "question": "Select the types of approvals you need", + "raw_answer": [ + "initial_discussion_or_promoting", + "demonstration_in_uk", + "demonstration_overseas", + "training", + "through_life_support", + "supply", + ], + }, + { + "key": "demonstration_in_uk", + "answer": "some UK demonstration reason", + "datatype": "string", + "question": "Explain what you are demonstrating and why", + "raw_answer": "some UK demonstration reason", + }, + { + "key": "demonstration_overseas", + "answer": "some overseas demonstration reason", + "datatype": "string", + "question": "Explain what you are demonstrating and why", + "raw_answer": "some overseas demonstration reason", + }, + { + "key": "approval_details_text", + "answer": "some details", + "datatype": "string", + "question": "Provide details about what you're seeking approval to do", + "raw_answer": "some details", + }, + ], + }, + "product_information": { + "label": "Product information", + "fields": [ + { + "key": "product_name", + "answer": "Test Info", + "raw_answer": "Test Info", + "question": "Give the item a descriptive name", + "datatype": "string", + }, + { + "key": "product_description", + "answer": "It does things", + "raw_answer": "It does things", + "question": "Describe the item", + "datatype": "string", + }, + { + "key": "is_foreign_tech_or_information_shared", + "answer": "Yes", + "raw_answer": True, + "question": "Will any foreign technology or information be shared with the item?", + "datatype": "boolean", + }, + { + "key": "is_controlled_under_itar", + "answer": "Yes, it's controlled under ITAR", + "raw_answer": True, + "question": "Is the technology or information controlled under the US International Traffic in Arms Regulations (ITAR)?", + "datatype": "boolean", + }, + { + "key": "controlled_info", + "answer": "It just is", + "raw_answer": "It just is", + "question": ( + "Explain how the technology or information is controlled.Include countries classification levels and " + "reference numbers. You can upload supporting documents later in your application" + ), + "datatype": "string", + }, + { + "key": "controlled_information", + "answer": "Some info", + "raw_answer": "Some info", + "question": "What is the ITAR controlled technology or information?", + "datatype": "string", + }, + { + "key": "itar_reference_number", + "answer": "123456", + "raw_answer": "123456", + "question": "ITAR reference number", + "datatype": "string", + }, + { + "key": "usml_categories", + "answer": "cat 1", + "raw_answer": "cat 1", + "question": "What are the United States Munitions List (USML) categories listed on your ITAR approval?", + "datatype": "string", + }, + { + "key": "itar_approval_scope", + "answer": "no scope", + "raw_answer": "no scope", + "question": "Describe the scope of your ITAR approval", + "datatype": "string", + }, + { + "key": "expected_time_in_possession", + "answer": "10 years", + "raw_answer": "10 years", + "question": "How long do you expect the technology or information that is controlled under the US ITAR to be in your possession?", + "datatype": "string", + }, + { + "key": "is_including_cryptography_or_security_features", + "answer": "Yes", + "raw_answer": True, + "question": "Does the item include cryptography or other information security features?", + "datatype": "boolean", + }, + { + "key": "cryptography_or_security_feature_info", + "answer": "some", + "raw_answer": "some", + "question": "Provide full details", + "datatype": "string", + }, + { + "key": "is_item_rated_under_mctr", + "answer": "Yes, the product is MTCR Category 1", + "raw_answer": "mtcr_1", + "question": "Do you believe the item is rated under the Missile Technology Control Regime (MTCR)", + "datatype": "string", + }, + { + "key": "is_item_manpad", + "answer": "No, the product is not a MANPAD", + "raw_answer": "no", + "question": "Do you believe the item is a man-portable air defence system (MANPAD)?", + "datatype": "string", + }, + { + "key": "is_mod_electronic_data_shared", + "answer": "No", + "raw_answer": "no", + "question": "Will any electronic warfare data owned by the Ministry of Defence (MOD) be shared with the item?", + "datatype": "string", + }, + { + "key": "funding_source", + "answer": "MOD", + "raw_answer": "mod", + "question": "Who is funding the item?", + "datatype": "string", + }, + { + "key": "is_used_by_uk_armed_forces", + "answer": "No", + "raw_answer": False, + "question": "Will the item be used by the UK Armed Forces?", + "datatype": "boolean", + }, + { + "key": "used_by_uk_armed_forces_info", + "answer": "", + "raw_answer": "", + "question": "Explain how it will be used", + "datatype": "string", + }, + ], + "type": "single", + }, + } + } + application_id = data_f680_case["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case) diff --git a/exporter/f680/application_sections/general_application_details/forms.py b/exporter/f680/application_sections/general_application_details/forms.py index ce46e3cfb2..69b791719b 100644 --- a/exporter/f680/application_sections/general_application_details/forms.py +++ b/exporter/f680/application_sections/general_application_details/forms.py @@ -1,4 +1,3 @@ -from datetime import datetime from django import forms from django.template.loader import render_to_string @@ -72,25 +71,6 @@ class Layout: widget=forms.Textarea(attrs={"rows": "5"}), ) - def __init__(self, *args, **kwargs): - # We have to do some coercion from string to datetime object here due to JSON serialization - if ( - "initial" in kwargs - and "exceptional_circumstances_date" in kwargs["initial"] - and isinstance(kwargs["initial"]["exceptional_circumstances_date"], str) - ): - kwargs["initial"]["exceptional_circumstances_date"] = datetime.fromisoformat( - kwargs["initial"]["exceptional_circumstances_date"] - ) - super().__init__(*args, **kwargs) - - def clean(self): - # We have to do some coercion from datetime object to string here due to JSON serialization - cleaned_data = super().clean() - if "exceptional_circumstances_date" in cleaned_data: - cleaned_data["exceptional_circumstances_date"] = cleaned_data["exceptional_circumstances_date"].isoformat() - return cleaned_data - def get_layout_fields(self): return ( "exceptional_circumstances_date", diff --git a/exporter/f680/application_sections/general_application_details/tests/test_views.py b/exporter/f680/application_sections/general_application_details/tests/test_views.py index 66c7dfa3bb..7f5ecedc66 100644 --- a/exporter/f680/application_sections/general_application_details/tests/test_views.py +++ b/exporter/f680/application_sections/general_application_details/tests/test_views.py @@ -28,6 +28,18 @@ def setup(mock_exporter_user_me, settings): settings.FEATURE_FLAG_ALLOW_F680 = True +@pytest.fixture() +def unset_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = ["12345"] + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture() +def set_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = [organisation_pk] + settings.FEATURE_FLAG_ALLOW_F680 = False + + @pytest.fixture def missing_application_id(): return "6bb0828c-1520-4624-b729-7f3e6e5b9f5d" @@ -49,19 +61,6 @@ def f680_application_wizard_url(data_f680_case): ) -@pytest.fixture -def mock_f680_application_get_404(requests_mock, missing_application_id): - url = client._build_absolute_uri(f"/exporter/f680/application/{missing_application_id}/") - return requests_mock.get(url=url, json={}, status_code=404) - - -@pytest.fixture -def mock_f680_application_get(requests_mock, data_f680_case): - application_id = data_f680_case["id"] - url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") - return requests_mock.get(url=url, json=data_f680_case) - - @pytest.fixture def force_exceptional_circumstances(goto_step, post_to_step): goto_step(FormSteps.EXCEPTIONAL_CIRCUMSTANCES) @@ -74,16 +73,40 @@ def force_exceptional_circumstances(goto_step, post_to_step): @pytest.fixture def mock_f680_application_get_existing_data(requests_mock, data_f680_case): data_f680_case["application"] = { - "general_application_details": { - "answers": { - "name": "my first F680", - "is_exceptional_circumstances": True, - "exceptional_circumstances_date": "2090-01-01", - "exceptional_circumstances_reason": "some reason", - }, - "questions": { - "name": "What is the name of the application?", - "is_exceptional_circumstances": "Are there exceptional circumstances?", + "sections": { + "general_application_details": { + "label": "General application details", + "type": "single", + "fields": [ + { + "key": "name", + "answer": "my first F680", + "raw_answer": "my first F680", + "question": "What is the name of the application?", + "datatype": "string", + }, + { + "key": "is_exceptional_circumstances", + "answer": "Yes", + "raw_answer": True, + "question": "Are there exceptional circumstances?", + "datatype": "boolean", + }, + { + "key": "exceptional_circumstances_date", + "answer": "2090-01-01", + "raw_answer": "2090-01-01", + "question": "What date do you need it?", + "datatype": "date", + }, + { + "key": "exceptional_circumstances_reason", + "answer": "some reason", + "raw_answer": "some reason", + "question": "What makes the circumstances exceptional?", + "datatype": "string", + }, + ], }, } } @@ -92,13 +115,6 @@ def mock_f680_application_get_existing_data(requests_mock, data_f680_case): return requests_mock.get(url=url, json=data_f680_case) -@pytest.fixture -def mock_patch_f680_application(requests_mock, data_f680_case): - application_id = data_f680_case["id"] - url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") - return requests_mock.patch(url=url, json=data_f680_case) - - @pytest.fixture def post_to_step(post_to_step_factory, f680_application_wizard_url): return post_to_step_factory(f680_application_wizard_url) @@ -130,6 +146,17 @@ def test_GET_success( assert response.status_code == 200 assert isinstance(response.context["form"], ApplicationNameForm) + def test_GET_success_organisation_set( + self, + authorized_client, + mock_f680_application_get, + f680_application_wizard_url, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_application_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], ApplicationNameForm) + def test_GET_no_feature_flag_forbidden( self, authorized_client, @@ -141,6 +168,17 @@ def test_GET_no_feature_flag_forbidden( assert response.status_code == 200 assert response.context["title"] == "Forbidden" + def test_GET_no_organisation_allowed( + self, + authorized_client, + mock_f680_application_get, + f680_application_wizard_url, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_application_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + @pytest.mark.parametrize( "step, data, expected_next_form", ( @@ -253,18 +291,40 @@ def test_POST_submit_wizard_success( assert mock_patch_f680_application.last_request.json() == { "application": { "name": "F680 Test 1", - "general_application_details": { - "answers": { - "name": "some test app", - "is_exceptional_circumstances": True, - "exceptional_circumstances_date": "2026-12-01", - "exceptional_circumstances_reason": "because", - }, - "questions": { - "name": "Name the application", - "is_exceptional_circumstances": "Do you have exceptional circumstances that mean you need F680 approval in less than 30 days?", - "exceptional_circumstances_date": "When do you need your F680 approval?", - "exceptional_circumstances_reason": "Why do you need approval in less than 30 days?", + "sections": { + "general_application_details": { + "label": "General application details", + "type": "single", + "fields": [ + { + "key": "name", + "answer": "some test app", + "raw_answer": "some test app", + "question": "Name the application", + "datatype": "string", + }, + { + "key": "is_exceptional_circumstances", + "answer": "Yes", + "raw_answer": True, + "question": "Do you have exceptional circumstances that mean you need F680 approval in less than 30 days?", + "datatype": "boolean", + }, + { + "key": "exceptional_circumstances_date", + "answer": "2026-12-01", + "raw_answer": "2026-12-01", + "question": "When do you need your F680 approval?", + "datatype": "date", + }, + { + "key": "exceptional_circumstances_reason", + "answer": "because", + "raw_answer": "because", + "question": "Why do you need approval in less than 30 days?", + "datatype": "string", + }, + ], }, }, } diff --git a/exporter/f680/application_sections/general_application_details/views.py b/exporter/f680/application_sections/general_application_details/views.py index 30ce244042..00688a2e13 100644 --- a/exporter/f680/application_sections/general_application_details/views.py +++ b/exporter/f680/application_sections/general_application_details/views.py @@ -19,3 +19,4 @@ class GeneralApplicationDetailsView(F680ApplicationSectionWizard): FormSteps.EXCEPTIONAL_CIRCUMSTANCES_REASONS: is_exceptional_circumstances, } section = "general_application_details" + section_label = "General application details" diff --git a/exporter/f680/application_sections/user_information/__init__.py b/exporter/f680/application_sections/user_information/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exporter/f680/application_sections/user_information/constants.py b/exporter/f680/application_sections/user_information/constants.py new file mode 100644 index 0000000000..7e0b5b752b --- /dev/null +++ b/exporter/f680/application_sections/user_information/constants.py @@ -0,0 +1,8 @@ +class FormSteps: + ENTITY_TYPE = "ENTITY_TYPE" + THIRD_PARTY_ROLE = "THIRD_PARTY_ROLE" + END_USER_NAME = "END_USER_NAME" + END_USER_ADDRESS = "END_USER_ADDRESS" + SECURITY_GRADING = "SECURITY_GRADING" + INTENDED_END_USE = "INTENDED_END_USE" + ASSEMBLE_MANUFACTURE = "ASSEMBLE_MANUFACTURE" diff --git a/exporter/f680/application_sections/user_information/forms.py b/exporter/f680/application_sections/user_information/forms.py new file mode 100644 index 0000000000..e9efbb612e --- /dev/null +++ b/exporter/f680/application_sections/user_information/forms.py @@ -0,0 +1,241 @@ +from django import forms +from crispy_forms_gds.choices import Choice +from crispy_forms_gds.fields import DateInputField + +from core.common.forms import BaseForm +from core.forms.layouts import F680ConditionalCheckboxes, F680ConditionalCheckboxesQuestion + + +class EntityTypeForm(BaseForm): + class Layout: + TITLE = "Select type of entity" + TITLE_AS_LABEL_FOR = "entity_type" + SUBMIT_BUTTON_TEXT = "Save and continue" + + entity_type = forms.ChoiceField( + choices=( + Choice( + "end-user", + "End user", + hint=( + "An end-user receives the products in the destination country. They either " + "use the products themselves, resell from stock, or export them again to another country." + ), + ), + Choice( + "ultimate-end-user", + "Ultimate end-user", + hint=( + "Ultimate end-users receive products or information from end-users. They can " + "be the same products or information that you shared with the end-user. Or the " + "end-user can change the products or information before sharing them with the ultimate end-user." + ), + ), + Choice( + "third-party", + "Third party", + hint=( + "A third party is involved in sharing products or information, but " + "doesn't use them. They are not an end-user or ultimate end-user. They may be an agent, " + "broker, consultant or distributor." + ), + ), + ), + label="Select type of entity", + widget=forms.RadioSelect, + ) + + def get_layout_fields(self): + return ("entity_type",) + + +class ThirdPartyRoleForm(BaseForm): + class Layout: + TITLE = "Select the role of the third party" + TITLE_AS_LABEL_FOR = "third_party_role" + SUBMIT_BUTTON_TEXT = "Save and continue" + + third_party_role = forms.ChoiceField( + choices=( + Choice( + "agent-or-broker", + "Agent or broker", + ), + Choice( + "intermediate-consignee", + "Intermediate consignee", + ), + Choice( + "authorised-submitter", + "Authorised submitter", + ), + Choice( + "consultant", + "Consultant", + ), + Choice( + "contact", + "Contact", + ), + Choice( + "exporter", + "Exporter", + ), + Choice( + "other", + "Other", + ), + ), + label=Layout.TITLE, + widget=forms.RadioSelect, + ) + + def get_layout_fields(self): + return ("third_party_role",) + + +class EndUserNameForm(BaseForm): + class Layout: + TITLE = "End-user name" + TITLE_AS_LABEL_FOR = "end_user_name" + SUBMIT_BUTTON_TEXT = "Save and continue" + + end_user_name = forms.CharField( + label="End-user name", + help_text="Name or organisation or individual", + ) + + def get_layout_fields(self): + return ("end_user_name",) + + +class EndUserAddressForm(BaseForm): + class Layout: + TITLE = "End-user address" + SUBMIT_BUTTON_TEXT = "Save and continue" + + address = forms.CharField( + label="Address", + widget=forms.Textarea(attrs={"rows": "5"}), + ) + country = forms.ChoiceField( + label="Country", + choices=[], + widget=forms.widgets.Select(attrs={"data-module": "autocomplete-select"}), + ) + + def __init__(self, *args, countries=None, **kwargs): + super().__init__(*args, **kwargs) + country_choices = [("", "")] + [(country["id"], country["name"]) for country in countries] + self.fields["country"].choices = country_choices + + def get_layout_fields(self): + return ("address", "country") + + +class SecurityGradingForm(BaseForm): + class Layout: + TITLE = "What is the security grading of the information or products you want to release to this end-user" + SUBMIT_BUTTON_TEXT = "Save and continue" + + prefix = forms.CharField( + label="Enter a prefix (optional)", + required=False, + ) + security_classification = forms.ChoiceField( + choices=( + Choice("unclassified", "Unclassified"), + Choice("official", "Official"), + Choice("official-sensitive", "Official-Sensitive"), + Choice("restricted", "Restricted"), + Choice("confidential", "Confidential"), + Choice("secret", "Secret"), + Choice("top-secret", "Top Secret", divider="Or"), + Choice("other", "Other"), + ), + label="Select security classification", + widget=forms.RadioSelect, + ) + other_security_classification = forms.CharField( + label="Enter the security classification", + required=False, + ) + suffix = forms.CharField( + label="Enter a suffix (optional)", + help_text="For example, UK eyes only", + required=False, + ) + issuing_authority_name_address = forms.CharField( + label="Name and address of the issuing authority", + widget=forms.Textarea(attrs={"rows": "5"}), + ) + reference = forms.CharField( + label="Reference", + ) + date_of_issue = DateInputField( + label="Date of issue", + help_text="For example, 27 3 2025", + ) + + def get_layout_fields(self): + return ( + "prefix", + "security_classification", + "other_security_classification", + "suffix", + "issuing_authority_name_address", + "reference", + "date_of_issue", + ) + + +class EndUserIntendedEndUseForm(BaseForm): + class Layout: + TITLE = "How does the end-user intend to use this product" + SUBMIT_BUTTON_TEXT = "Save and continue" + + end_user_intended_end_use = forms.CharField( + label="How does the end-user intend to use this product", + widget=forms.Textarea(attrs={"rows": "5"}), + help_text="Include as much information as you can. We need to know if they will integrate it into other equipment, involve any third parties, etc.", + ) + + def get_layout_fields(self): + return ("end_user_intended_end_use",) + + +class EndUserAssembleManufactureForm(BaseForm): + class Layout: + TITLE = "Does this end-user need to assemble or manufacture any of the products?" + SUBMIT_BUTTON_TEXT = "Save and continue" + + assemble_manufacture_choices = ( + Choice("assemble", "Yes, assembled"), + Choice("manufacture", "Yes, manufactured"), + Choice("no", "No"), + ) + + assemble_manufacture = forms.MultipleChoiceField( + choices=assemble_manufacture_choices, + label=Layout.TITLE, + ) + assemble = forms.CharField( + label="Describe what assembly is needed.", + required=False, + widget=forms.Textarea(attrs={"rows": 5}), + ) + manufacture = forms.CharField( + label="Describe what manufacture is needed. Be sure to include the manufacturer's website if they have one.", + required=False, + widget=forms.Textarea(attrs={"rows": 5}), + ) + + def __init__(self, *args, **kwargs): + self.conditional_checkbox_choices = ( + F680ConditionalCheckboxesQuestion(choices.label, choices.value) + for choices in self.assemble_manufacture_choices + ) + super().__init__(*args, **kwargs) + + def get_layout_fields(self): + return (F680ConditionalCheckboxes("assemble_manufacture", *self.conditional_checkbox_choices),) diff --git a/exporter/f680/application_sections/user_information/tests/__init__.py b/exporter/f680/application_sections/user_information/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exporter/f680/application_sections/user_information/tests/test_views.py b/exporter/f680/application_sections/user_information/tests/test_views.py new file mode 100644 index 0000000000..484ceaa430 --- /dev/null +++ b/exporter/f680/application_sections/user_information/tests/test_views.py @@ -0,0 +1,880 @@ +import pytest +from datetime import datetime + +from django.urls import reverse +from freezegun import freeze_time + +from core import client + +from exporter.f680.application_sections.user_information import forms +from exporter.f680.application_sections.user_information.constants import FormSteps + + +@pytest.fixture(autouse=True) +def setup_tests(mock_countries): + pass + + +@pytest.fixture +def missing_f680_application_wizard_url(missing_application_id): + return reverse( + "f680:user_information:wizard", + kwargs={"pk": missing_application_id}, + ) + + +@pytest.fixture +def f680_user_information_wizard_url(data_f680_case): + return reverse( + "f680:user_information:wizard", + kwargs={"pk": data_f680_case["id"]}, + ) + + +@pytest.fixture +def f680_edit_user_information_wizard_url(data_f680_case, data_item_id): + return reverse( + "f680:user_information:wizard", + kwargs={"pk": data_f680_case["id"], "id": data_item_id}, + ) + + +@pytest.fixture +def force_third_party(goto_step, post_to_step): + goto_step(FormSteps.ENTITY_TYPE) + post_to_step( + FormSteps.ENTITY_TYPE, + {"entity_type": "third_party"}, + ) + + +@pytest.fixture +def data_item_id(): + return "d7b483ff-5d70-415f-a040-5866d6a7cb1b" # /PS-IGNORE + + +@pytest.fixture +def mock_f680_application_get_existing_data(requests_mock, data_f680_case, data_item_id): + data_f680_case["application"] = { + "name": "vfd", + "sections": { + "user_information": { + "items": [ + { + "fields": [ + { + "answer": "End user", + "datatype": "string", + "key": "entity_type", + "question": "Select type of entity", + "raw_answer": "end-user", + }, + { + "answer": "some end user name", + "datatype": "string", + "key": "end_user_name", + "question": "End-user name", + "raw_answer": "some end user name", + }, + { + "answer": "some address", + "datatype": "string", + "key": "address", + "question": "Address", + "raw_answer": "some address", + }, + { + "answer": "United States", + "datatype": "string", + "key": "country", + "question": "Country", + "raw_answer": "US", + }, + { + "answer": "some prefix", + "datatype": "string", + "key": "prefix", + "question": "Enter a prefix (optional)", + "raw_answer": "some prefix", + }, + { + "answer": "Unclassified", + "datatype": "string", + "key": "security_classification", + "question": "Select security classification", + "raw_answer": "unclassified", + }, + { + "answer": "", + "datatype": "string", + "key": "other_security_classification", + "question": "Enter the security classification", + "raw_answer": "", + }, + { + "answer": "some suffix", + "datatype": "string", + "key": "suffix", + "question": "Enter a suffix (optional)", + "raw_answer": "some suffix", + }, + { + "answer": "some name and address", + "datatype": "string", + "key": "issuing_authority_name_address", + "question": "Name and address of the issuing authority", + "raw_answer": "some name and address", + }, + { + "answer": "some reference", + "datatype": "string", + "key": "reference", + "question": "Reference", + "raw_answer": "some reference", + }, + { + "answer": "2024-01-01", + "datatype": "date", + "key": "date_of_issue", + "question": "Date of issue", + "raw_answer": "2024-01-01", + }, + { + "answer": "some end use", + "datatype": "string", + "key": "end_user_intended_end_use", + "question": "How does the end-user intend to use this product", + "raw_answer": "some end use", + }, + { + "answer": ["Yes, assembled", "Yes, manufactured"], + "datatype": "list", + "key": "assemble_manufacture", + "question": "Does this end-user need to assemble or manufacture any of the products?", + "raw_answer": ["assemble", "manufacture"], + }, + { + "answer": "some assembly", + "datatype": "string", + "key": "assemble", + "question": "Describe what assembly is needed.", + "raw_answer": "some assembly", + }, + { + "answer": "some manufacture", + "datatype": "string", + "key": "manufacture", + "question": "Describe what manufacture is needed. Be sure to include the manufacturer's website if they have one.", + "raw_answer": "some manufacture", + }, + ], + "id": data_item_id, + } + ], + "label": "User Information", + "type": "multiple", + } + }, + } + application_id = data_f680_case["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case) + + +@pytest.fixture +def post_to_step(post_to_step_factory, f680_user_information_wizard_url): + return post_to_step_factory(f680_user_information_wizard_url) + + +@pytest.fixture +def goto_step(goto_step_factory, f680_user_information_wizard_url): + return goto_step_factory(f680_user_information_wizard_url) + + +@pytest.fixture +def goto_edit_step(goto_step_factory, f680_edit_user_information_wizard_url): + return goto_step_factory(f680_edit_user_information_wizard_url) + + +class TestUserInformationView: + + def test_GET_no_application_404( + self, + authorized_client, + missing_f680_application_wizard_url, + mock_f680_application_get_404, + ): + response = authorized_client.get(missing_f680_application_wizard_url) + assert response.status_code == 404 + + def test_GET_success( + self, + authorized_client, + mock_f680_application_get, + f680_user_information_wizard_url, + ): + response = authorized_client.get(f680_user_information_wizard_url) + assert response.status_code == 200 + assert isinstance(response.context["form"], forms.EntityTypeForm) + + def test_GET_no_feature_flag_forbidden( + self, + authorized_client, + mock_f680_application_get, + f680_user_information_wizard_url, + unset_f680_feature_flag, + ): + response = authorized_client.get(f680_user_information_wizard_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" + + @pytest.mark.parametrize( + "step, data, expected_next_form", + ( + (FormSteps.ENTITY_TYPE, {"entity_type": "end-user"}, forms.EndUserNameForm), + ( + FormSteps.END_USER_NAME, + {"end_user_name": "some end user name"}, + forms.EndUserAddressForm, + ), + ( + FormSteps.END_USER_ADDRESS, + {"address": "some end user address", "country": "US"}, + forms.SecurityGradingForm, + ), + ( + FormSteps.SECURITY_GRADING, + { + "prefix": "some prefix", + "security_classification": "unclassified", + "suffix": "some suffix", + "issuing_authority_name_address": "some name address", + "reference": "some reference", + "date_of_issue_0": "1", + "date_of_issue_1": "1", + "date_of_issue_2": "2025", + }, + forms.EndUserIntendedEndUseForm, + ), + ( + FormSteps.INTENDED_END_USE, + {"end_user_intended_end_use": "some end use"}, + forms.EndUserAssembleManufactureForm, + ), + ), + ) + def test_POST_to_step_success( + self, + step, + data, + expected_next_form, + post_to_step, + goto_step, + mock_f680_application_get, + ): + goto_step(step) + response = post_to_step( + step, + data, + ) + assert response.status_code == 200 + assert isinstance(response.context["form"], expected_next_form) + + @pytest.mark.parametrize( + "step, data, expected_errors", + ( + (FormSteps.ENTITY_TYPE, {"entity_type": ""}, {"entity_type": ["This field is required."]}), + ( + FormSteps.THIRD_PARTY_ROLE, + {}, + {"third_party_role": ["This field is required."]}, + ), + ( + FormSteps.END_USER_NAME, + {}, + {"end_user_name": ["This field is required."]}, + ), + ( + FormSteps.END_USER_ADDRESS, + {}, + {"address": ["This field is required."], "country": ["This field is required."]}, + ), + ( + FormSteps.SECURITY_GRADING, + { + "security_classification": "unclassified", + }, + { + "issuing_authority_name_address": ["This field is required."], + "reference": ["This field is required."], + "date_of_issue": ["Enter the day, month and year"], + }, + ), + ( + FormSteps.INTENDED_END_USE, + {}, + {"end_user_intended_end_use": ["This field is required."]}, + ), + ( + FormSteps.ASSEMBLE_MANUFACTURE, + {}, + {"assemble_manufacture": ["This field is required."]}, + ), + ), + ) + def test_POST_to_step_validation_error( + self, + step, + data, + expected_errors, + post_to_step, + goto_step, + mock_f680_application_get, + force_third_party, + ): + goto_step(step) + response = post_to_step( + step, + data, + ) + assert response.status_code == 200 + for field_name, error in expected_errors.items(): + assert response.context["form"][field_name].errors == error + + @freeze_time("2026-11-30") + def test_POST_submit_wizard_success( + self, post_to_step, goto_step, mock_f680_application_get, mock_patch_f680_application + ): + response = post_to_step( + FormSteps.ENTITY_TYPE, + {"entity_type": "third-party"}, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.ThirdPartyRoleForm + + response = post_to_step( + FormSteps.THIRD_PARTY_ROLE, + {"third_party_role": "consultant"}, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserNameForm + + response = post_to_step( + FormSteps.END_USER_NAME, + { + "end_user_name": "some end user name", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserAddressForm + + response = post_to_step( + FormSteps.END_USER_ADDRESS, + { + "address": "some end user address", + "country": "US", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.SecurityGradingForm + + response = post_to_step( + FormSteps.SECURITY_GRADING, + { + "prefix": "some prefix", + "security_classification": "secret", + "suffix": "some suffix", + "issuing_authority_name_address": "some name address", + "reference": "some ref", + "date_of_issue_0": "1", + "date_of_issue_1": "1", + "date_of_issue_2": "2024", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserIntendedEndUseForm + + response = post_to_step( + FormSteps.INTENDED_END_USE, + { + "end_user_intended_end_use": "some end use", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserAssembleManufactureForm + + response = post_to_step( + FormSteps.ASSEMBLE_MANUFACTURE, + { + "assemble_manufacture": ["assemble", "manufacture"], + "assemble": "some assemble reason", + "manufacture": "some manufacture reason", + }, + ) + assert response.status_code == 302 + assert mock_patch_f680_application.called_once + generated_uuid = mock_patch_f680_application.last_request.json()["application"]["sections"]["user_information"][ + "items" + ][0]["id"] + assert mock_patch_f680_application.last_request.json() == { + "application": { + "name": "F680 Test 1", + "sections": { + "user_information": { + "label": "User Information", + "items": [ + { + "id": generated_uuid, + "fields": [ + { + "key": "entity_type", + "answer": "Third party", + "raw_answer": "third-party", + "question": "Select type of entity", + "datatype": "string", + }, + { + "key": "third_party_role", + "answer": "Consultant", + "raw_answer": "consultant", + "question": "Select the role of the third party", + "datatype": "string", + }, + { + "key": "end_user_name", + "answer": "some end user name", + "raw_answer": "some end user name", + "question": "End-user name", + "datatype": "string", + }, + { + "key": "address", + "answer": "some end user address", + "raw_answer": "some end user address", + "question": "Address", + "datatype": "string", + }, + { + "key": "country", + "answer": "United States", + "raw_answer": "US", + "question": "Country", + "datatype": "string", + }, + { + "key": "prefix", + "answer": "some prefix", + "raw_answer": "some prefix", + "question": "Enter a prefix (optional)", + "datatype": "string", + }, + { + "key": "security_classification", + "answer": "Secret", + "raw_answer": "secret", + "question": "Select security classification", + "datatype": "string", + }, + { + "key": "other_security_classification", + "answer": "", + "raw_answer": "", + "question": "Enter the security classification", + "datatype": "string", + }, + { + "key": "suffix", + "answer": "some suffix", + "raw_answer": "some suffix", + "question": "Enter a suffix (optional)", + "datatype": "string", + }, + { + "key": "issuing_authority_name_address", + "answer": "some name address", + "raw_answer": "some name address", + "question": "Name and address of the issuing authority", + "datatype": "string", + }, + { + "key": "reference", + "answer": "some ref", + "raw_answer": "some ref", + "question": "Reference", + "datatype": "string", + }, + { + "key": "date_of_issue", + "answer": "2024-01-01", + "raw_answer": "2024-01-01", + "question": "Date of issue", + "datatype": "date", + }, + { + "key": "end_user_intended_end_use", + "answer": "some end use", + "raw_answer": "some end use", + "question": "How does the end-user intend to use this product", + "datatype": "string", + }, + { + "key": "assemble_manufacture", + "answer": ["Yes, assembled", "Yes, manufactured"], + "raw_answer": ["assemble", "manufacture"], + "question": "Does this end-user need to assemble or manufacture any of the products?", + "datatype": "list", + }, + { + "key": "assemble", + "answer": "some assemble reason", + "raw_answer": "some assemble reason", + "question": "Describe what assembly is needed.", + "datatype": "string", + }, + { + "key": "manufacture", + "answer": "some manufacture reason", + "raw_answer": "some manufacture reason", + "question": "Describe what manufacture is needed. Be sure to include the manufacturer's website if they have one.", + "datatype": "string", + }, + ], + } + ], + "type": "multiple", + } + }, + } + } + + @freeze_time("2026-11-30") + def test_POST_submit_wizard_existing_user_information_success( + self, + post_to_step, + goto_step, + mock_f680_application_get_existing_data, + mock_patch_f680_application, + data_item_id, + ): + response = post_to_step( + FormSteps.ENTITY_TYPE, + {"entity_type": "third-party"}, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.ThirdPartyRoleForm + + response = post_to_step( + FormSteps.THIRD_PARTY_ROLE, + {"third_party_role": "consultant"}, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserNameForm + + response = post_to_step( + FormSteps.END_USER_NAME, + { + "end_user_name": "some end user name", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserAddressForm + + response = post_to_step( + FormSteps.END_USER_ADDRESS, + { + "address": "some end user address", + "country": "US", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.SecurityGradingForm + + response = post_to_step( + FormSteps.SECURITY_GRADING, + { + "prefix": "some prefix", + "security_classification": "secret", + "suffix": "some suffix", + "issuing_authority_name_address": "some name address", + "reference": "some ref", + "date_of_issue_0": "1", + "date_of_issue_1": "1", + "date_of_issue_2": "2024", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserIntendedEndUseForm + + response = post_to_step( + FormSteps.INTENDED_END_USE, + { + "end_user_intended_end_use": "some end use", + }, + ) + assert response.status_code == 200 + assert type(response.context["form"]) == forms.EndUserAssembleManufactureForm + + response = post_to_step( + FormSteps.ASSEMBLE_MANUFACTURE, + { + "assemble_manufacture": ["assemble", "manufacture"], + "assemble": "some assemble reason", + "manufacture": "some manufacture reason", + }, + ) + assert response.status_code == 302 + assert mock_patch_f680_application.called_once + api_patch_payload = mock_patch_f680_application.last_request.json() + generated_uuid = mock_patch_f680_application.last_request.json()["application"]["sections"]["user_information"][ + "items" + ][1]["id"] + # existing record still present + assert api_patch_payload["application"]["sections"]["user_information"]["items"][0]["id"] == data_item_id + + # New record also present + assert api_patch_payload["application"]["sections"]["user_information"]["items"][1] == { + "id": generated_uuid, + "fields": [ + { + "key": "entity_type", + "answer": "Third party", + "raw_answer": "third-party", + "question": "Select type of entity", + "datatype": "string", + }, + { + "key": "third_party_role", + "answer": "Consultant", + "raw_answer": "consultant", + "question": "Select the role of the third party", + "datatype": "string", + }, + { + "key": "end_user_name", + "answer": "some end user name", + "raw_answer": "some end user name", + "question": "End-user name", + "datatype": "string", + }, + { + "key": "address", + "answer": "some end user address", + "raw_answer": "some end user address", + "question": "Address", + "datatype": "string", + }, + { + "key": "country", + "answer": "United States", + "raw_answer": "US", + "question": "Country", + "datatype": "string", + }, + { + "key": "prefix", + "answer": "some prefix", + "raw_answer": "some prefix", + "question": "Enter a prefix (optional)", + "datatype": "string", + }, + { + "key": "security_classification", + "answer": "Secret", + "raw_answer": "secret", + "question": "Select security classification", + "datatype": "string", + }, + { + "key": "other_security_classification", + "answer": "", + "raw_answer": "", + "question": "Enter the security classification", + "datatype": "string", + }, + { + "key": "suffix", + "answer": "some suffix", + "raw_answer": "some suffix", + "question": "Enter a suffix (optional)", + "datatype": "string", + }, + { + "key": "issuing_authority_name_address", + "answer": "some name address", + "raw_answer": "some name address", + "question": "Name and address of the issuing authority", + "datatype": "string", + }, + { + "key": "reference", + "answer": "some ref", + "raw_answer": "some ref", + "question": "Reference", + "datatype": "string", + }, + { + "key": "date_of_issue", + "answer": "2024-01-01", + "raw_answer": "2024-01-01", + "question": "Date of issue", + "datatype": "date", + }, + { + "key": "end_user_intended_end_use", + "answer": "some end use", + "raw_answer": "some end use", + "question": "How does the end-user intend to use this product", + "datatype": "string", + }, + { + "key": "assemble_manufacture", + "answer": ["Yes, assembled", "Yes, manufactured"], + "raw_answer": ["assemble", "manufacture"], + "question": "Does this end-user need to assemble or manufacture any of the products?", + "datatype": "list", + }, + { + "key": "assemble", + "answer": "some assemble reason", + "raw_answer": "some assemble reason", + "question": "Describe what assembly is needed.", + "datatype": "string", + }, + { + "key": "manufacture", + "answer": "some manufacture reason", + "raw_answer": "some manufacture reason", + "question": "Describe what manufacture is needed. Be sure to include the manufacturer's website if they have one.", + "datatype": "string", + }, + ], + } + + @pytest.mark.parametrize( + "step, expected_form, expected_initial", + ( + (FormSteps.ENTITY_TYPE, forms.EntityTypeForm, {"entity_type": "end-user"}), + ( + FormSteps.END_USER_NAME, + forms.EndUserNameForm, + {"end_user_name": "some end user name"}, + ), + ( + FormSteps.END_USER_ADDRESS, + forms.EndUserAddressForm, + {"address": "some address", "country": "US"}, + ), + ( + FormSteps.SECURITY_GRADING, + forms.SecurityGradingForm, + { + "prefix": "some prefix", + "security_classification": "unclassified", + "suffix": "some suffix", + "issuing_authority_name_address": "some name and address", + "reference": "some reference", + "date_of_issue": datetime(year=2024, month=1, day=1), + }, + ), + ( + FormSteps.INTENDED_END_USE, + forms.EndUserIntendedEndUseForm, + {"end_user_intended_end_use": "some end use"}, + ), + ( + FormSteps.ASSEMBLE_MANUFACTURE, + forms.EndUserAssembleManufactureForm, + {}, + ), + ), + ) + def test_GET_with_existing_data_success( + self, + step, + expected_form, + expected_initial, + mock_f680_application_get_existing_data, + goto_edit_step, + ): + response = goto_edit_step(step) + assert response.status_code == 200 + assert isinstance(response.context["form"], expected_form) + for key, expected_value in expected_initial.items(): + assert response.context["form"][key].initial == expected_value + + +@pytest.fixture +def f680_user_information_summary_url(data_f680_case): + return reverse( + "f680:user_information:summary", + kwargs={"pk": data_f680_case["id"]}, + ) + + +@pytest.fixture +def missing_f680_user_information_summary_url(missing_application_id): + return reverse( + "f680:user_information:summary", + kwargs={"pk": missing_application_id}, + ) + + +class TestUserInformationSummaryView: + + def test_GET_with_existing_data_success( + self, + authorized_client, + f680_user_information_summary_url, + mock_f680_application_get_existing_data, + data_item_id, + ): + response = authorized_client.get(f680_user_information_summary_url) + assert response.status_code == 200 + assert response.context["user_entities"] == { + data_item_id: { + "entity_type": "End user", + "end_user_name": "some end user name", + "address": "some address", + "country": "United States", + "prefix": "some prefix", + "security_classification": "Unclassified", + "other_security_classification": "", + "suffix": "some suffix", + "issuing_authority_name_address": "some name and address", + "reference": "some reference", + "date_of_issue": "2024-01-01", + "end_user_intended_end_use": "some end use", + "assemble_manufacture": ["Yes, assembled", "Yes, manufactured"], + "assemble": "some assembly", + "manufacture": "some manufacture", + } + } + + def test_GET_no_application_404( + self, + authorized_client, + missing_f680_user_information_summary_url, + mock_f680_application_get_404, + ): + response = authorized_client.get(missing_f680_application_wizard_url) + assert response.status_code == 404 + + def test_GET_no_user_entities_redirects( + self, + authorized_client, + f680_user_information_summary_url, + mock_f680_application_get, + ): + response = authorized_client.get(f680_user_information_summary_url) + assert response.status_code == 302 + + def test_GET_no_feature_flag_forbidden( + self, + authorized_client, + mock_f680_application_get, + f680_user_information_summary_url, + unset_f680_feature_flag, + ): + response = authorized_client.get(f680_user_information_summary_url) + assert response.status_code == 200 + assert response.context["title"] == "Forbidden" diff --git a/exporter/f680/application_sections/user_information/urls.py b/exporter/f680/application_sections/user_information/urls.py new file mode 100644 index 0000000000..c3bd338696 --- /dev/null +++ b/exporter/f680/application_sections/user_information/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + + +app_name = "user_information" + +urlpatterns = [ + path("add-item/", views.UserInformationView.as_view(), name="wizard"), + path("edit-item//", views.UserInformationView.as_view(), name="wizard"), + path("summary/", views.UserInformationSummaryView.as_view(), name="summary"), +] diff --git a/exporter/f680/application_sections/user_information/views.py b/exporter/f680/application_sections/user_information/views.py new file mode 100644 index 0000000000..205179ae09 --- /dev/null +++ b/exporter/f680/application_sections/user_information/views.py @@ -0,0 +1,106 @@ +from http import HTTPStatus + +from django.shortcuts import redirect +from django.views.generic import TemplateView +from django.urls import reverse + +from core.decorators import expect_status + +from exporter.core.services import get_countries +from exporter.f680.views import F680FeatureRequiredMixin +from exporter.f680.services import get_f680_application + +from exporter.f680.application_sections.views import F680MultipleItemApplicationSectionWizard + + +from .constants import FormSteps +from .forms import ( + EntityTypeForm, + ThirdPartyRoleForm, + EndUserNameForm, + EndUserAddressForm, + SecurityGradingForm, + EndUserIntendedEndUseForm, + EndUserAssembleManufactureForm, +) + + +def is_third_party(wizard): + cleaned_data = wizard.get_cleaned_data_for_step(FormSteps.ENTITY_TYPE) or {} + return cleaned_data.get("entity_type") == "third-party" + + +class UserInformationView(F680MultipleItemApplicationSectionWizard): + form_list = [ + (FormSteps.ENTITY_TYPE, EntityTypeForm), + (FormSteps.THIRD_PARTY_ROLE, ThirdPartyRoleForm), + (FormSteps.END_USER_NAME, EndUserNameForm), + (FormSteps.END_USER_ADDRESS, EndUserAddressForm), + (FormSteps.SECURITY_GRADING, SecurityGradingForm), + (FormSteps.INTENDED_END_USE, EndUserIntendedEndUseForm), + (FormSteps.ASSEMBLE_MANUFACTURE, EndUserAssembleManufactureForm), + ] + condition_dict = { + FormSteps.THIRD_PARTY_ROLE: is_third_party, + } + section = "user_information" + section_label = "User Information" + + def get_form_kwargs(self, step, *args, **kwargs): + if step == FormSteps.END_USER_ADDRESS: + countries = get_countries(self.request, False, ["GB"]) + return {"countries": countries} + return {} + + def get_success_url(self, application_id): + return reverse( + "f680:user_information:summary", + kwargs={ + "pk": application_id, + }, + ) + + +class UserInformationSummaryView(F680FeatureRequiredMixin, TemplateView): + template_name = "f680/user_information/summary.html" + + @expect_status( + HTTPStatus.OK, + "Error getting F680 application", + "Unexpected error getting F680 application", + reraise_404=True, + ) + def get_f680_application(self, application_id): + return get_f680_application(self.request, application_id) + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.application, _ = self.get_f680_application(kwargs["pk"]) + self.user_entities = self.get_user_entities() + + def get(self, request, *args, **kwargs): + if not self.user_entities: + return redirect( + reverse( + "f680:user_information:wizard", + kwargs={ + "pk": self.application["id"], + }, + ) + ) + return super().get(request, *args, **kwargs) + + def get_user_entities(self): + if not self.application.get("application", {}).get("sections", {}).get("user_information", {}).get("items"): + return {} + user_entities = {} + for entity in self.application["application"]["sections"]["user_information"]["items"]: + answers = {field["key"]: field["answer"] for field in entity["fields"]} + user_entities[entity["id"]] = answers + return user_entities + + def get_context_data(self, pk, **kwargs): + return { + "application": self.application, + "user_entities": self.user_entities, + } diff --git a/exporter/f680/application_sections/views.py b/exporter/f680/application_sections/views.py index 6cc27eb84f..a1fb641dab 100644 --- a/exporter/f680/application_sections/views.py +++ b/exporter/f680/application_sections/views.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from datetime import datetime from django.shortcuts import redirect from django.urls import reverse @@ -8,7 +9,7 @@ from core.wizard.views import BaseSessionWizardView from exporter.f680.services import patch_f680_application, get_f680_application -from exporter.f680.payloads import F680PatchPayloadBuilder +from exporter.f680.payloads import F680PatchPayloadBuilder, F680AppendingPayloadBuilder from exporter.f680.views import F680FeatureRequiredMixin @@ -38,8 +39,23 @@ def get_f680_application(self, pk): def patch_f680_application(self, data): return patch_f680_application(self.request, self.application["id"], data) + def deserialize(self, value, datatype): + if datatype == "date": + return datetime.fromisoformat(value) + return value + + def deserialize_payload(self, payload): + data = {} + for field in payload["fields"]: + key = field["key"] + data[key] = self.deserialize(field["raw_answer"], field["datatype"]) + return data + def get_form_initial(self, step): - return self.application.get("application", {}).get(self.section, {}).get("answers", {}) + if not self.application.get("application", {}).get("sections", {}).get(self.section): + return {} + + return self.deserialize_payload(self.application["application"]["sections"][self.section]) def get_success_url(self, application_id): return reverse( @@ -51,9 +67,33 @@ def get_success_url(self, application_id): def get_payload(self, form_dict): current_application = self.application.get("application", {}) - return F680PatchPayloadBuilder().build(self.section, current_application, form_dict) + return F680PatchPayloadBuilder().build(self.section, self.section_label, current_application, form_dict) def done(self, form_list, form_dict, **kwargs): data = self.get_payload(form_dict) response_data, _ = self.patch_f680_application(data) return redirect(self.get_success_url(response_data["id"])) + + +class F680MultipleItemApplicationSectionWizard(F680ApplicationSectionWizard): + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.id = None + if "id" in kwargs: + self.id = str(kwargs["id"]) + + def get_form_initial(self, step): + if not self.id: + return {} + + all_items = self.application["application"]["sections"][self.section]["items"] + existing_items = {item["id"]: item for item in all_items} + item = existing_items[self.id] + return self.deserialize_payload(item) + + def get_payload(self, form_dict): + current_application = self.application.get("application", {}) + return F680AppendingPayloadBuilder().build( + self.section, self.section_label, current_application, form_dict, self.id + ) diff --git a/exporter/f680/constants.py b/exporter/f680/constants.py index 452db76305..dd0771b0cf 100644 --- a/exporter/f680/constants.py +++ b/exporter/f680/constants.py @@ -1,6 +1,2 @@ -class ApplicationFormSteps: - APPLICATION_NAME = "APPLICATION_NAME" - - class ApprovalTypeSteps: APPROVAL_TYPE = "APPROVAL_TYPE" diff --git a/exporter/f680/forms.py b/exporter/f680/forms.py index d9e1118874..788d66bb44 100644 --- a/exporter/f680/forms.py +++ b/exporter/f680/forms.py @@ -1,23 +1,6 @@ -from django import forms - from core.common.forms import BaseForm -class ApplicationNameForm(BaseForm): - class Layout: - TITLE = "Name of the application" - TITLE_AS_LABEL_FOR = "name" - SUBMIT_BUTTON_TEXT = "Continue" - - name = forms.CharField( - label="", - help_text="Give the application a reference name so you can refer back to it when needed", - ) - - def get_layout_fields(self): - return ("name",) - - class ApplicationSubmissionForm(BaseForm): class Layout: TITLE = "" diff --git a/exporter/f680/payloads.py b/exporter/f680/payloads.py index b7e84b492f..e57a528b33 100644 --- a/exporter/f680/payloads.py +++ b/exporter/f680/payloads.py @@ -1,26 +1,85 @@ -from deepmerge import always_merger +from uuid import uuid4 +from datetime import date -from core.wizard.payloads import MergingPayloadBuilder, get_cleaned_data, get_questions_data -from .constants import ApplicationFormSteps +class F680PatchPayloadBuilder: + def serialize(self, value): + if isinstance(value, bool): + return value, "boolean" + elif isinstance(value, date): + return value.isoformat(), "date" + elif isinstance(value, str): + return value, "string" + elif isinstance(value, list): + return value, "list" + else: + raise NotImplementedError(f"Must implement serialization for value {value} of type {type(value)}") -class F680CreatePayloadBuilder(MergingPayloadBuilder): - payload_dict = { - ApplicationFormSteps.APPLICATION_NAME: get_cleaned_data, - } + def get_display_answer(self, form, field_name, answer): + if isinstance(answer, list): + return [self.get_display_answer(form, field_name, answer_value) for answer_value in answer] + return dict(form.fields[field_name].choices)[answer] - def build(self, form_dict): - payload = super().build(form_dict) - return {"application": payload} + def get_fields(self, form): + fields = [] + for field_name, value in form.cleaned_data.items(): + serialized_answer, datatype = self.serialize(value) + answer = serialized_answer + if hasattr(form.fields[field_name], "choices"): + answer = self.get_display_answer(form, field_name, answer) + fields.append( + { + "key": field_name, + "answer": answer, + "raw_answer": serialized_answer, + "question": form[field_name].label, + "datatype": datatype, + } + ) + return fields + def get_all_fields(self, forms): + fields = [] + for form in forms: + fields.extend(self.get_fields(form)) + return fields + + def build(self, section, section_label, application_data, form_dict): + fields = self.get_all_fields(form_dict.values()) + section_payload = { + "label": section_label, + "fields": fields, + "type": "single", + } + try: + application_data["sections"][section] = section_payload + except KeyError: + application_data["sections"] = {section: section_payload} + return {"application": application_data} + + +class F680AppendingPayloadBuilder(F680PatchPayloadBuilder): + def build(self, section, section_label, application_data, form_dict, item_id=None): + if not item_id: + item_id = str(uuid4()) + + fields = self.get_all_fields(form_dict.values()) + + all_items = {} + if application_data.get("sections", {}).get(section, {}).get("items"): + flat_items = application_data["sections"][section]["items"] + all_items = {item["id"]: item for item in flat_items} + item = {"id": item_id, "fields": fields} + all_items[item["id"]] = item + + section_payload = { + "label": section_label, + "items": list(all_items.values()), + "type": "multiple", + } + try: + application_data["sections"][section] = section_payload + except KeyError: + application_data["sections"] = {section: section_payload} -class F680PatchPayloadBuilder: - def build(self, section, application_data, form_dict): - answer_payload = {} - question_payload = {} - for form in form_dict.values(): - if form: - always_merger.merge(answer_payload, get_cleaned_data(form)) - always_merger.merge(question_payload, get_questions_data(form)) - application_data[section] = {"answers": answer_payload, "questions": question_payload} return {"application": application_data} diff --git a/exporter/f680/services.py b/exporter/f680/services.py index 195751217a..be19ab1e70 100644 --- a/exporter/f680/services.py +++ b/exporter/f680/services.py @@ -14,3 +14,8 @@ def get_f680_application(request, application_id): def patch_f680_application(request, application_id, json): data = client.patch(request, f"/exporter/f680/application/{application_id}/", json) return data.json(), data.status_code + + +def submit_f680_application(request, application_id): + data = client.post(request, f"/exporter/f680/application/{application_id}/submit") + return data.json(), data.status_code diff --git a/exporter/f680/tests/__init__.py b/exporter/f680/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exporter/f680/tests/test_views.py b/exporter/f680/tests/test_views.py new file mode 100644 index 0000000000..f867ac169d --- /dev/null +++ b/exporter/f680/tests/test_views.py @@ -0,0 +1,347 @@ +import pytest +from uuid import uuid4 + +from bs4 import BeautifulSoup +from django.urls import reverse +from pytest_django.asserts import assertTemplateUsed + +from core import client +from exporter.f680.forms import ApplicationSubmissionForm + + +@pytest.fixture(autouse=True) +def setup(mock_exporter_user_me, settings): + settings.FEATURE_FLAG_ALLOW_F680 = True + + +@pytest.fixture +def authorized_client(authorized_client_factory, mock_exporter_user): + return authorized_client_factory(mock_exporter_user["user"]) + + +@pytest.fixture +def f680_apply_url(): + return reverse("f680:apply") + + +@pytest.fixture +def data_f680_case(data_organisation): + return { + "id": "6cf7b401-62dc-4577-ad1d-4282f2aabc96", + "application": {"name": "F680 Test 1"}, + "reference_code": None, + "organisation": { + "id": "3913ff20-5a2b-468a-bf5d-427228459b06", + "name": "Archway Communications", + "type": "commercial", + "status": "active", + }, + "submitted_at": None, + "submitted_by": None, + } + + +@pytest.fixture +def data_f680_case_complete_application(data_f680_case): + data_f680_case["application"] = { + "sections": { + "general_application_details": {}, + "approval_type": {}, + "user_information": {}, + "product_information": {}, + } + } + return data_f680_case + + +@pytest.fixture +def data_f680_case_partially_complete_application(data_f680_case): + data_f680_case["application"] = { + "sections": { + "general_application_details": {}, + "user_information": {}, + } + } + return data_f680_case + + +@pytest.fixture +def f680_summary_url_with_application(data_f680_case): + return reverse("f680:summary", kwargs={"pk": data_f680_case["id"]}) + + +@pytest.fixture +def post_to_step(post_to_step_factory, f680_apply_url, mock_application_post): + return post_to_step_factory(f680_apply_url) + + +@pytest.fixture +def mock_f680_application_get(requests_mock, data_f680_case): + application_id = data_f680_case["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case) + + +@pytest.fixture +def mock_f680_application_get_application_complete(requests_mock, data_f680_case_complete_application): + application_id = data_f680_case_complete_application["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case_complete_application) + + +@pytest.fixture +def mock_f680_application_get_application_partially_complete( + requests_mock, data_f680_case_partially_complete_application +): + application_id = data_f680_case_partially_complete_application["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") + return requests_mock.get(url=url, json=data_f680_case_partially_complete_application) + + +@pytest.fixture +def mock_application_post(requests_mock, data_f680_case): + application = data_f680_case + url = client._build_absolute_uri(f"/exporter/f680/application/") + return requests_mock.post(url=url, json=application, status_code=201) + + +@pytest.fixture +def mock_f680_application_submit(requests_mock, data_f680_case_complete_application): + application_id = data_f680_case_complete_application["id"] + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/submit/") + return requests_mock.post(url=url, json=data_f680_case_complete_application) + + +@pytest.fixture() +def unset_f680_feature_flag(settings): + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture() +def unset_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = ["12345"] + settings.FEATURE_FLAG_ALLOW_F680 = False + + +@pytest.fixture() +def set_f680_allowed_organisation(settings, organisation_pk): + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = [organisation_pk] + settings.FEATURE_FLAG_ALLOW_F680 = False + + +class TestApplyForLicenceQuestionsClass: + def test_triage_f680_apply_redirect_success(self, authorized_client, f680_apply_url): + response = authorized_client.post(reverse("apply_for_a_licence:f680_questions")) + assert response.status_code == 302 + assert response.url == f680_apply_url + + +class TestF680ApplicationCreateView: + def test_get_create_f680_view_success( + self, + authorized_client, + f680_apply_url, + f680_summary_url_with_application, + mock_application_post, + ): + response = authorized_client.get(f680_apply_url) + assert response.status_code == 302 + assert response.url == f680_summary_url_with_application + assert mock_application_post.called_once + assert mock_application_post.last_request.json() == {"application": {}} + + def test_get_create_f680_view_success_allowed_organisation( + self, + authorized_client, + f680_apply_url, + f680_summary_url_with_application, + mock_application_post, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_apply_url) + assert response.status_code == 302 + assert response.url == f680_summary_url_with_application + assert mock_application_post.called_once + assert mock_application_post.last_request.json() == {"application": {}} + + def test_get_create_f680_view_fail_with_feature_flag_off( + self, + authorized_client, + f680_apply_url, + mock_f680_application_get, + unset_f680_feature_flag, + ): + response = authorized_client.get(f680_apply_url) + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) + + def test_get_create_f680_view_fail_with_feature_organidation_not_allowed( + self, + authorized_client, + f680_apply_url, + mock_f680_application_get, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_apply_url) + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) + + +class TestF680ApplicationSummaryView: + def test_get_f680_summary_view_success( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + ): + response = authorized_client.get(f680_summary_url_with_application) + + assert isinstance(response.context["form"], ApplicationSubmissionForm) + assertTemplateUsed(response, "f680/summary.html") + + content = BeautifulSoup(response.content, "html.parser") + heading_element = content.find("h1", class_="govuk-heading-l govuk-!-margin-bottom-2") + assert heading_element.string.strip() == "F680 Application" + + def test_get_f680_summary_view_success_organisation_allowed( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + set_f680_allowed_organisation, + ): + response = authorized_client.get(f680_summary_url_with_application) + + assert isinstance(response.context["form"], ApplicationSubmissionForm) + assertTemplateUsed(response, "f680/summary.html") + + content = BeautifulSoup(response.content, "html.parser") + heading_element = content.find("h1", class_="govuk-heading-l govuk-!-margin-bottom-2") + assert heading_element.string.strip() == "F680 Application" + + def test_get_f680_summary_view_case_not_found( + self, + authorized_client, + requests_mock, + ): + + app_pk = str(uuid4()) + client_uri = client._build_absolute_uri(f"/exporter/f680/application/{app_pk}/") + + requests_mock.get(client_uri, json={}, status_code=404) + + response = authorized_client.get(reverse("f680:summary", kwargs={"pk": app_pk})) + assert response.status_code == 404 + + def test_get_f680_summary_view_fail_with_feature_flag_off( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + unset_f680_feature_flag, + ): + response = authorized_client.get(f680_summary_url_with_application) + assert response.status_code == 200 + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) + + def test_get_f680_summary_view_fail_with_feature_organisation_not_allowed( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + unset_f680_allowed_organisation, + ): + response = authorized_client.get(f680_summary_url_with_application) + assert response.status_code == 200 + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) + + def test_post_f680_submission_form_missing_all_sections_returns_errors( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + ): + response = authorized_client.post( + f680_summary_url_with_application, + ) + + assert response.status_code == 200 + assert response.context["errors"] == {"missing_sections": ["Please complete all required sections"]} + + def test_post_f680_submission_form_partially_complete_returns_errors( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get_application_partially_complete, + ): + response = authorized_client.post( + f680_summary_url_with_application, + ) + + assert response.status_code == 200 + assert response.context["errors"] == {"missing_sections": ["Please complete all required sections"]} + + def test_post_f680_submission_form_success( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get_application_complete, + mock_f680_application_submit, + data_f680_case_complete_application, + ): + response = authorized_client.post( + f680_summary_url_with_application, + ) + + assert response.status_code == 302 + assert response.url == reverse( + "applications:success_page", kwargs={"pk": data_f680_case_complete_application["id"]} + ) + + def test_post_f680_submission_form_fail_with_feature_flag_off( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + unset_f680_feature_flag, + ): + response = authorized_client.post( + f680_summary_url_with_application, + ) + + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) + + def test_post_f680_submission_form_fail_with_organisation_not_allowed( + self, + authorized_client, + f680_summary_url_with_application, + mock_f680_application_get, + unset_f680_allowed_organisation, + ): + response = authorized_client.post( + f680_summary_url_with_application, + ) + + assert response.context[0].get("title") == "Forbidden" + assert ( + "You are not authorised to use the F680 Security Clearance application feature" + in response.context[0].get("description").args + ) diff --git a/exporter/f680/urls.py b/exporter/f680/urls.py index 070fd5cef4..50b6490afa 100644 --- a/exporter/f680/urls.py +++ b/exporter/f680/urls.py @@ -20,4 +20,8 @@ "/additional-information/", include("exporter.f680.application_sections.additional_information.urls"), ), + path( + "/user-information/", + include("exporter.f680.application_sections.user_information.urls"), + ), ] diff --git a/exporter/f680/views.py b/exporter/f680/views.py index 175ef5a9df..723f82c572 100644 --- a/exporter/f680/views.py +++ b/exporter/f680/views.py @@ -1,34 +1,26 @@ from http import HTTPStatus +import rules -from django.conf import settings from django.contrib.auth.mixins import AccessMixin from django.shortcuts import redirect from django.urls import reverse -from django.views.generic import FormView +from django.views.generic import FormView, RedirectView from core.auth.views import LoginRequiredMixin from core.decorators import expect_status -from core.wizard.views import BaseSessionWizardView -from .constants import ( - ApplicationFormSteps, -) -from .forms import ( - ApplicationNameForm, - ApplicationSubmissionForm, -) -from .payloads import ( - F680CreatePayloadBuilder, -) +from .forms import ApplicationSubmissionForm + from .services import ( post_f680_application, get_f680_application, + submit_f680_application, ) class F680FeatureRequiredMixin(AccessMixin): def dispatch(self, request, *args, **kwargs): - if not settings.FEATURE_FLAG_ALLOW_F680: + if not rules.test_rule("can_exporter_use_f680s", request): self.raise_exception = True self.permission_denied_message = ( "You are not authorised to use the F680 Security Clearance application feature" @@ -37,10 +29,7 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) -class F680ApplicationCreateView(LoginRequiredMixin, F680FeatureRequiredMixin, BaseSessionWizardView): - form_list = [ - (ApplicationFormSteps.APPLICATION_NAME, ApplicationNameForm), - ] +class F680ApplicationCreateView(LoginRequiredMixin, F680FeatureRequiredMixin, RedirectView): @expect_status( HTTPStatus.CREATED, @@ -58,11 +47,10 @@ def get_success_url(self, application_id): }, ) - def get_payload(self, form_dict): - return F680CreatePayloadBuilder().build(form_dict) - - def done(self, form_list, form_dict, **kwargs): - data = self.get_payload(form_dict) + def get(self, request, *args, **kwargs): + super().get(request, *args, **kwargs) + # Data required to create a base application + data = {"application": {}} response_data, _ = self.post_f680_application(data) return redirect(self.get_success_url(response_data["id"])) @@ -71,6 +59,10 @@ class F680ApplicationSummaryView(LoginRequiredMixin, F680FeatureRequiredMixin, F form_class = ApplicationSubmissionForm template_name = "f680/summary.html" + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + self.application, _ = self.get_f680_application(kwargs["pk"]) + @expect_status( HTTPStatus.OK, "Error getting F680 application", @@ -80,15 +72,43 @@ class F680ApplicationSummaryView(LoginRequiredMixin, F680FeatureRequiredMixin, F def get_f680_application(self, application_id): return get_f680_application(self.request, application_id) - def setup(self, request, *args, **kwargs): - super().setup(request, *args, **kwargs) - self.application, _ = self.get_f680_application(kwargs["pk"]) - def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["application"] = self.application - return context + @expect_status( + HTTPStatus.OK, + "Error submitting F680 application", + "Unexpected error submitting F680 application", + reraise_404=True, + ) + def submit_f680_application(self, application_id): + return submit_f680_application(self.request, application_id) + + def all_sections_complete(self): + # TODO: Think more about pre-submit validation as this is very barebones right now + complete_sections = set(self.application["application"].get("sections", {}).keys()) + required_sections = set( + [ + "general_application_details", + "approval_type", + "user_information", + "product_information", + ] + ) + missing_sections = required_sections - complete_sections + return len(missing_sections) == 0, missing_sections + + def form_valid(self, form): + is_sections_completed, _ = self.all_sections_complete() + if not is_sections_completed: + context_data = self.get_context_data(form=form) + context_data["errors"] = {"missing_sections": ["Please complete all required sections"]} + return self.render_to_response(context_data) + + self.submit_f680_application(self.application["id"]) + return super().form_valid(form) + def get_success_url(self): - return reverse("f680:summary", kwargs={"pk": self.application["id"]}) + return reverse("applications:success_page", kwargs={"pk": self.application["id"]}) diff --git a/exporter/templates/f680/forms/help_ITAR.html b/exporter/templates/f680/forms/help_ITAR.html new file mode 100644 index 0000000000..b036063f77 --- /dev/null +++ b/exporter/templates/f680/forms/help_ITAR.html @@ -0,0 +1,5 @@ +

+ ITAR is the United States regulation that controls the export of sensitive goods and technology.

+ You can apply for approval to share unclassified ITAR items separately using our US unclassified ITAR application. You can get a decision faster using this application.

+ If you have classified UK items, please continue with this F680 for those items. +

diff --git a/exporter/templates/f680/forms/help_electronic_warfare_data.html b/exporter/templates/f680/forms/help_electronic_warfare_data.html new file mode 100644 index 0000000000..58a5c38db6 --- /dev/null +++ b/exporter/templates/f680/forms/help_electronic_warfare_data.html @@ -0,0 +1,16 @@ +

+ MOD owned electronic warfare data includes any data or information in the following forms: +

    +
  • pre and post analysed Emitter Parametric data and Emitter related information
  • +
  • authoritative data on all emitters, platforms and weapons
  • +
  • electronic fits of all systems including Electromagnetic Order of Battle (EOB)
  • +
  • emitter geographic location data and information
  • +
  • electronic warfare evaluation and research and development
  • +
  • technical and administrative documentation required for continuous exchange of data
  • +
  • operational electronic warfare information including Lawful Intercepts
  • +
  • Mission Dependent Data for all ESM and RWR fitted platforms
  • +
  • electromagnetic countermeasures (CM) advice and the provision of CM (for example, DIRCM codes)
  • +
  • algorithms and waveforms required to make software and EW systems work
  • +
  • representative emitter data used for training, trials and capability development
  • +
+

diff --git a/exporter/templates/f680/forms/help_manpads.html b/exporter/templates/f680/forms/help_manpads.html new file mode 100644 index 0000000000..eb756d0301 --- /dev/null +++ b/exporter/templates/f680/forms/help_manpads.html @@ -0,0 +1,3 @@ +

+ MANPADS are are lightweight anti-aircraft weapons. They are designed to protect soldiers on the battlefield from attacking aircraft +

diff --git a/exporter/templates/f680/forms/help_mctr_categories.html b/exporter/templates/f680/forms/help_mctr_categories.html new file mode 100644 index 0000000000..a9fa933eb2 --- /dev/null +++ b/exporter/templates/f680/forms/help_mctr_categories.html @@ -0,0 +1,5 @@ +

+ MTCR Category 1 items include complete rockets and unmanned aerial vehicle systems that can deliver a payload of at least 500kg to a range of at least 300km.

+ MTCR Category 2 items include missile systems and unmanned aerial vehicles that can deliver a payload of less than 500kg to a range of 300km.

+ For both Categories, unmanned aerial vehicles include ballistic missiles, space launch vehicles, sounding rockets, cruise missiles, target drones and reconnaissance drones. +

diff --git a/exporter/templates/f680/forms/help_product_description.html b/exporter/templates/f680/forms/help_product_description.html new file mode 100644 index 0000000000..d8871ad431 --- /dev/null +++ b/exporter/templates/f680/forms/help_product_description.html @@ -0,0 +1 @@ +

Incorporating an item means integrating it with a higher level system, platform or software.

diff --git a/exporter/templates/f680/forms/help_security_features.html b/exporter/templates/f680/forms/help_security_features.html new file mode 100644 index 0000000000..015f172551 --- /dev/null +++ b/exporter/templates/f680/forms/help_security_features.html @@ -0,0 +1,3 @@ +

+ Information security features include cryptography, and cryptanalytic functions. They are often found in communication, wireless or internet-based products, digital forensics and surveillance tools. +

diff --git a/exporter/templates/f680/forms/help_security_grading.html b/exporter/templates/f680/forms/help_security_grading.html new file mode 100644 index 0000000000..4d07ec54db --- /dev/null +++ b/exporter/templates/f680/forms/help_security_grading.html @@ -0,0 +1,8 @@ +

+ The government classifies information assets to ensure they are appropriately protected.

+ Guidance on government security gradings (opens in new tab)

+ The grading can sometimes include a prefix and suffix. There are many in use and so it is important that you know the full classification of the product.

+ If the item was developed with Ministry of Defence (MOD) funding you will find the grading in the Security Aspects Letter provided by the MOD project team.

+ If the item was developed with overseas government support, that government is responsible for providing the grading.

+ If the item was developed without UK or overseas government support, you should apply for a private venture grading using SPIRE - the online export licensing system. The grading will be provided by MOD. +

diff --git a/exporter/templates/f680/forms/subtitle_product_description.html b/exporter/templates/f680/forms/subtitle_product_description.html new file mode 100644 index 0000000000..b421608b4f --- /dev/null +++ b/exporter/templates/f680/forms/subtitle_product_description.html @@ -0,0 +1,13 @@ + +
+

Include as much information as you can. Explain:
+

    +
  • What the item is including the make and model
  • +
  • how you plan to share it
  • +
  • if anyone else will use it
  • +
  • if it will be incorporated into other items
  • +
+ If you're using a substitute product you must include it in your application. You must also include the original product that is being substituted.

+ You can upload supporting documents later in your application explaining more about the item. +

+
diff --git a/exporter/templates/f680/forms/subtitle_product_name.html b/exporter/templates/f680/forms/subtitle_product_name.html new file mode 100644 index 0000000000..53f4318d14 --- /dev/null +++ b/exporter/templates/f680/forms/subtitle_product_name.html @@ -0,0 +1,10 @@ +

We need to understand what you will be sharing with non-uk entities. + This includes:
+

  • physical products
  • +
  • substitute products
  • +
  • agreements
  • +
  • brochures
  • +
  • manuals
  • +
  • training guides
  • +
+

diff --git a/exporter/templates/f680/summary.html b/exporter/templates/f680/summary.html index cc1994cde2..3d31c64d4f 100644 --- a/exporter/templates/f680/summary.html +++ b/exporter/templates/f680/summary.html @@ -5,6 +5,27 @@ {% block title %}Apply for an F680 Application{% endblock%} {% block body %} +{% if errors %} + +{% endif %} +

F680 Application

@@ -29,7 +50,7 @@

  • General application details - {% if application.application.general_application_details %} + {% if application.application.sections.general_application_details %}
    Completed
    @@ -41,6 +62,8 @@

  • + +

    2. Complete approval details @@ -49,7 +72,21 @@

  • Approval type - {% if application.application.approval_details %} + {% if application.application.sections.approval_type %} +
    + Completed +
    + {% else %} +
    + Not Started +
    + {% endif %} +
    +
  • +
  • +
    + Product information + {% if application.application.sections.product_information %}
    Completed
    @@ -60,7 +97,31 @@

    {% endif %}

  • + + +
  • +

    + 3. + Tell us who is involved +

    +
      +
    • +
      + User information + {% if application.application.sections.user_information %} +
      + Completed +
      + {% else %} +
      + Not Started +
      + {% endif %} +
      +
    +
  • +
  • 4. Additional Information @@ -69,13 +130,13 @@

  • Notes for case officers - {% if application.application.additional_information.answers.note %} + {% if application.application.sections.notes_for_case_officers %}
    Completed
    {% else %}
    - Not Started + Optional
    {% endif %}
    diff --git a/exporter/templates/f680/user_information/summary.html b/exporter/templates/f680/user_information/summary.html new file mode 100644 index 0000000000..75f511cd0b --- /dev/null +++ b/exporter/templates/f680/user_information/summary.html @@ -0,0 +1,45 @@ +{% extends "layouts/base.html" %} + +{% block title %}F680 User information{% endblock%} + +{% block back_link_url %}{% url "f680:summary" application.id %}{% endblock %} + +{% block body %} +
    +
    +

    Tell us about the entity

    + + + + + + + + + + + {% for id, user_entity in user_entities.items %} + + + + + + {% endfor %} + +
    NameEntity typeActions
    + {{user_entity.end_user_name}} + + {{user_entity.entity_type}} + + + Edit + +
    + Add a new entity +

    + Save and continue +

    + +
    +
    +{% endblock %} diff --git a/unit_tests/conftest.py b/unit_tests/conftest.py index 41512096df..0f8dca8f0a 100644 --- a/unit_tests/conftest.py +++ b/unit_tests/conftest.py @@ -555,23 +555,6 @@ def data_open_case(): } -@pytest.fixture -def data_f680_case(data_organisation): - return { - "id": "6cf7b401-62dc-4577-ad1d-4282f2aabc96", - "application": {"name": "F680 Test 1"}, - "reference_code": None, - "organisation": { - "id": "3913ff20-5a2b-468a-bf5d-427228459b06", - "name": "Archway Communications", - "type": "commercial", - "status": "active", - }, - "submitted_at": None, - "submitted_by": None, - } - - @pytest.fixture def data_standard_case( data_organisation, diff --git a/unit_tests/exporter/applications/views/test_f680s.py b/unit_tests/exporter/applications/views/test_f680s.py deleted file mode 100644 index 8bd6d6c305..0000000000 --- a/unit_tests/exporter/applications/views/test_f680s.py +++ /dev/null @@ -1,197 +0,0 @@ -import pytest -from uuid import uuid4 - -from bs4 import BeautifulSoup -from django.urls import reverse -from pytest_django.asserts import assertTemplateUsed - -from core import client -from exporter.f680.constants import ( - ApplicationFormSteps, -) -from exporter.f680.forms import ApplicationNameForm, ApplicationSubmissionForm - - -@pytest.fixture(autouse=True) -def setup(settings): - settings.FEATURE_FLAG_ALLOW_F680 = True - - -@pytest.fixture -def authorized_client(authorized_client_factory, mock_exporter_user): - return authorized_client_factory(mock_exporter_user["user"]) - - -@pytest.fixture -def f680_apply_url(): - return reverse("f680:apply") - - -@pytest.fixture -def f680_summary_url_with_application(data_f680_case): - return reverse("f680:summary", kwargs={"pk": data_f680_case["id"]}) - - -@pytest.fixture -def post_to_step(post_to_step_factory, f680_apply_url, mock_application_post): - return post_to_step_factory(f680_apply_url) - - -@pytest.fixture -def mock_f680_application_get(requests_mock, data_f680_case): - application_id = data_f680_case["id"] - url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") - return requests_mock.get(url=url, json=data_f680_case) - - -@pytest.fixture -def mock_application_post(requests_mock, data_f680_case): - application = data_f680_case - url = client._build_absolute_uri(f"/exporter/f680/application/") - return requests_mock.post(url=url, json=application, status_code=201) - - -@pytest.fixture() -def unset_f680_feature_flag(settings): - settings.FEATURE_FLAG_ALLOW_F680 = False - - -class TestApplyForLicenceQuestionsClass: - def test_triage_f680_apply_redirect_success(self, authorized_client, f680_apply_url): - response = authorized_client.post(reverse("apply_for_a_licence:f680_questions")) - assert response.status_code == 302 - assert response.url == f680_apply_url - - -class TestF680ApplicationCreateView: - def test_get_create_f680_view_success( - self, - authorized_client, - f680_apply_url, - mock_f680_application_get, - ): - response = authorized_client.get(f680_apply_url) - - assert isinstance(response.context["form"], ApplicationNameForm) - soup = BeautifulSoup(response.content, "html.parser") - assert "Name of the application" in soup.find("h1").text - - def test_get_create_f680_view_fail_with_feature_flag_off( - self, - authorized_client, - f680_apply_url, - mock_f680_application_get, - unset_f680_feature_flag, - ): - response = authorized_client.get(f680_apply_url) - assert response.context[0].get("title") == "Forbidden" - assert ( - "You are not authorised to use the F680 Security Clearance application feature" - in response.context[0].get("description").args - ) - - def test_post_to_create_f680_name_step_success( - self, - authorized_client, - f680_apply_url, - post_to_step, - f680_summary_url_with_application, - ): - response = post_to_step( - ApplicationFormSteps.APPLICATION_NAME, - {"name": "F680 Test"}, - ) - - assert response.status_code == 302 - assert response.url == f680_summary_url_with_application - - def test_post_to_create_f680_name_step_invalid_data( - self, - authorized_client, - f680_apply_url, - post_to_step, - ): - response = post_to_step( - ApplicationFormSteps.APPLICATION_NAME, - {"name": ""}, - ) - - assert isinstance(response.context["form"], ApplicationNameForm) - assert response.context["form"].errors - assert response.context["form"].errors.get("name")[0] == "This field is required." - - -class TestF680ApplicationSummaryView: - def test_get_f680_summary_view_success( - self, - authorized_client, - f680_summary_url_with_application, - mock_f680_application_get, - ): - response = authorized_client.get(f680_summary_url_with_application) - - assert isinstance(response.context["form"], ApplicationSubmissionForm) - assertTemplateUsed(response, "f680/summary.html") - - content = BeautifulSoup(response.content, "html.parser") - heading_element = content.find("h1", class_="govuk-heading-l govuk-!-margin-bottom-2") - assert heading_element.string.strip() == "F680 Application" - - def test_get_f680_summary_view_case_not_found( - self, - authorized_client, - requests_mock, - ): - - app_pk = str(uuid4()) - client_uri = client._build_absolute_uri(f"/exporter/f680/application/{app_pk}/") - - requests_mock.get(client_uri, json={}, status_code=404) - - response = authorized_client.get(reverse("f680:summary", kwargs={"pk": app_pk})) - assert response.status_code == 404 - - def test_get_f680_summary_view_fail_with_feature_flag_off( - self, - authorized_client, - f680_summary_url_with_application, - mock_f680_application_get, - unset_f680_feature_flag, - ): - response = authorized_client.get(f680_summary_url_with_application) - assert response.status_code == 200 - assert response.context[0].get("title") == "Forbidden" - assert ( - "You are not authorised to use the F680 Security Clearance application feature" - in response.context[0].get("description").args - ) - - def test_post_f680_submission_form_success( - self, - authorized_client, - f680_summary_url_with_application, - mock_f680_application_get, - ): - response = authorized_client.post( - f680_summary_url_with_application, - ) - - assert response.status_code == 302 - assert response.url == f680_summary_url_with_application - - def test_post_f680_submission_form_fail_with_feature_flag_off( - self, - authorized_client, - f680_summary_url_with_application, - mock_f680_application_get, - unset_f680_feature_flag, - ): - response = authorized_client.post( - f680_summary_url_with_application, - ) - - assert response.context[0].get("title") == "Forbidden" - assert ( - "You are not authorised to use the F680 Security Clearance application feature" - in response.context[0].get("description").args - ) diff --git a/unit_tests/exporter/applications/views/test_success_page.py b/unit_tests/exporter/applications/views/test_success_page.py index a9c59261ab..8e36165f85 100644 --- a/unit_tests/exporter/applications/views/test_success_page.py +++ b/unit_tests/exporter/applications/views/test_success_page.py @@ -76,6 +76,12 @@ def test_success_view( assert soup.find("input", {"id": "submit-id-submit"})["value"] == "Submit and continue" +def test_apply_for_licence_start_view(authorized_client, application_start_url): + response = authorized_client.get(application_start_url) + + assert response.status_code == 200 + + def test_post_survey_feedback(authorized_client, success_url, application_pk, survey_id, mock_post_survey): response = authorized_client.post(success_url, data={"satisfaction_rating": "NEITHER"}) assert response.status_code == 302 diff --git a/unit_tests/exporter/apply_for_a_licence/forms/test_triage_questions.py b/unit_tests/exporter/apply_for_a_licence/forms/test_triage_questions.py index 4e8ca98db3..2aa4d8f9b3 100644 --- a/unit_tests/exporter/apply_for_a_licence/forms/test_triage_questions.py +++ b/unit_tests/exporter/apply_for_a_licence/forms/test_triage_questions.py @@ -14,20 +14,58 @@ (False, False, ["export_licence", "transhipment", "trade_control_licence"], ["f680"]), ), ) -def test_opening_question_feature_flags(settings, siel_only_allowed, f680_allowed, expect_enabled, expect_disabled): +def test_opening_question_feature_flags( + rf, client, settings, siel_only_allowed, f680_allowed, expect_enabled, expect_disabled +): + request = rf.get("/") + request.session = client.session + session = request.session + session["organisation"] = "123" + session.save() + # given the flag is set or unset settings.FEATURE_FLAG_ONLY_ALLOW_SIEL = siel_only_allowed settings.FEATURE_FLAG_ALLOW_F680 = f680_allowed reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) # when the form is created - form = triage_questions.opening_question() + form = triage_questions.opening_question(request) # then the disabled options reflect the feature flag assert [item.key for item in form.questions[0].options if not item.disabled] == expect_enabled assert [item.key for item in form.questions[0].options if item.disabled] == expect_disabled +@pytest.mark.parametrize( + "f680_allowed, f680_orgs_allowed, user_org, expect_enabled", + ( + (True, [], "1234", ["export_licence", "f680"]), + (False, [], "1234", ["export_licence"]), + (False, ["1234"], "1234", ["export_licence", "f680"]), + (False, ["1234"], "12345", ["export_licence"]), + ), +) +def test_opening_question_f680_feature_flags( + rf, client, settings, f680_allowed, f680_orgs_allowed, user_org, expect_enabled +): + request = rf.get("/") + request.session = client.session + session = request.session + session["organisation"] = user_org + session.save() + + # given the flag is set + settings.FEATURE_FLAG_ALLOW_F680 = f680_allowed + settings.FEATURE_FLAG_F680_ALLOWED_ORGANISATIONS = f680_orgs_allowed + reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) + + # when the form is created + form = triage_questions.opening_question(request) + + # then the disabled options reflect the feature flag + assert [item.key for item in form.questions[0].options if not item.disabled] == expect_enabled + + @pytest.mark.parametrize( "value, expect_enabled, expect_disabled", (