diff --git a/conf/base.py b/conf/base.py index ded6f4d022..97c570085b 100644 --- a/conf/base.py +++ b/conf/base.py @@ -352,3 +352,4 @@ def show_toolbar(request): GTM_ID = env.str("GTM_ID", default="") GIT_COMMIT = env.str("GIT_COMMIT", default="") +FEATURE_FLAG_ALLOW_F680 = env.bool("FEATURE_FLAG_ALLOW_F680", default=False) # PS-IGNORE diff --git a/conf/exporter.py b/conf/exporter.py index c2baebadfe..8fa601a577 100644 --- a/conf/exporter.py +++ b/conf/exporter.py @@ -18,6 +18,7 @@ "exporter.applications", "exporter.organisation", "exporter.goods", + "exporter.f680", ] if MOCK_SSO_ACTIVATE_ENDPOINTS: diff --git a/exporter/apply_for_a_licence/forms/triage_questions.py b/exporter/apply_for_a_licence/forms/triage_questions.py index ca59916011..a12dafbc76 100644 --- a/exporter/apply_for_a_licence/forms/triage_questions.py +++ b/exporter/apply_for_a_licence/forms/triage_questions.py @@ -34,6 +34,16 @@ def opening_question(): "before you provide access to controlled technology, software or data." ), ), + Option( + key="f680", # PS-IGNORE + value="Security Approval", + description=( + "Select if you need approval to give classified products or information to non-UK organisations, " + "governments and individuals. This includes F680 approval. You should apply for security approval" # PS-IGNORE + " before you apply for a licence." + ), + disabled=not settings.FEATURE_FLAG_ALLOW_F680, # PS-IGNORE + ), Option( key="transhipment", value="Transhipment licence", @@ -52,15 +62,6 @@ def opening_question(): ), disabled=settings.FEATURE_FLAG_ONLY_ALLOW_SIEL, ), - Option( - key="mod", - value="MOD clearance", - description=( - "Select if you need to share information (an F680) or to go to an exhibition, or if you're gifting " - "surplus products." - ), - disabled=settings.FEATURE_FLAG_ONLY_ALLOW_SIEL, - ), ] if settings.FEATURE_FLAG_ONLY_ALLOW_SIEL: description = render_to_string("applications/use-spire-triage.html") diff --git a/exporter/apply_for_a_licence/urls.py b/exporter/apply_for_a_licence/urls.py index 4c6d51edf2..f5adc0c9b3 100644 --- a/exporter/apply_for_a_licence/urls.py +++ b/exporter/apply_for_a_licence/urls.py @@ -14,7 +14,12 @@ if not settings.FEATURE_FLAG_ONLY_ALLOW_SIEL: urlpatterns += [ path("transhipment/", views.TranshipmentQuestions.as_view(), name="transhipment_questions"), - path("mod/", views.MODClearanceQuestions.as_view(), name="mod_questions"), + # path("mod/", views.MODClearanceQuestions.as_view(), name="mod_questions"), path("/", views.OpenGeneralLicenceQuestions.as_view(), name="ogl_questions"), path("//", views.OpenGeneralLicenceSubmit.as_view(), name="ogl_submit"), ] + +if settings.FEATURE_FLAG_ALLOW_F680: # /PS-IGNORE + urlpatterns += [ + path("f680/", views.F680Questions.as_view(), name="f680_questions"), # /PS-IGNORE + ] diff --git a/exporter/apply_for_a_licence/views.py b/exporter/apply_for_a_licence/views.py index bf71d3098d..26f6854342 100644 --- a/exporter/apply_for_a_licence/views.py +++ b/exporter/apply_for_a_licence/views.py @@ -18,7 +18,7 @@ from exporter.core.services import post_open_general_licence_cases from lite_forms.views import SingleFormView, MultiFormView -from core.auth.views import LoginRequiredMixin +from core.auth.views import LoginRequiredMixin, RedirectView class LicenceType(LoginRequiredMixin, SingleFormView): @@ -69,17 +69,17 @@ def get_success_url(self): return reverse_lazy("applications:task_list", kwargs={"pk": pk}) -class MODClearanceQuestions(LoginRequiredMixin, MultiFormView): - def init(self, request, **kwargs): - self.forms = MOD_questions(None) - self.action = post_applications +# class MODClearanceQuestions(LoginRequiredMixin, MultiFormView): +# def init(self, request, **kwargs): +# self.forms = MOD_questions(None) +# self.action = post_applications - def on_submission(self, request, **kwargs): - self.forms = MOD_questions(request.POST.copy().get("application_type")) +# def on_submission(self, request, **kwargs): +# self.forms = MOD_questions(request.POST.copy().get("application_type")) - def get_success_url(self): - pk = self.get_validated_data()["id"] - return reverse_lazy("applications:task_list", kwargs={"pk": pk}) +# def get_success_url(self): +# pk = self.get_validated_data()["id"] +# return reverse_lazy("applications:task_list", kwargs={"pk": pk}) class OpenGeneralLicenceQuestions(LoginRequiredMixin, MultiFormView): @@ -101,3 +101,8 @@ def get_success_url(self): class OpenGeneralLicenceSubmit(LoginRequiredMixin, TemplateView): def get(self, request, *args, **kwargs): return open_general_licence_submit_success_page(request, **kwargs) + + +class F680Questions(LoginRequiredMixin, RedirectView): # /PS-IGNORE + def get_redirect_url(self, *args, **kwargs): + return reverse("f680:apply") # /PS-IGNORE diff --git a/exporter/f680/__init__.py b/exporter/f680/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exporter/f680/constants.py b/exporter/f680/constants.py new file mode 100644 index 0000000000..17ba9c48cd --- /dev/null +++ b/exporter/f680/constants.py @@ -0,0 +1,2 @@ +class ApplicationFormSteps: + APPLICATION_NAME = "APPLICATION_NAME" diff --git a/exporter/f680/forms.py b/exporter/f680/forms.py new file mode 100644 index 0000000000..d9e1118874 --- /dev/null +++ b/exporter/f680/forms.py @@ -0,0 +1,27 @@ +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 = "" + SUBMIT_BUTTON_TEXT = "Submit" + + def get_layout_fields(self): + return [] diff --git a/exporter/f680/payloads.py b/exporter/f680/payloads.py new file mode 100644 index 0000000000..f023db6d7d --- /dev/null +++ b/exporter/f680/payloads.py @@ -0,0 +1,13 @@ +from core.wizard.payloads import MergingPayloadBuilder +from exporter.applications.views.goods.common.payloads import get_cleaned_data +from .constants import ApplicationFormSteps # /PS-IGNORE + + +class F680CreatePayloadBuilder(MergingPayloadBuilder): # /PS-IGNORE + payload_dict = { + ApplicationFormSteps.APPLICATION_NAME: get_cleaned_data, # /PS-IGNORE + } + + def build(self, form_dict): + payload = super().build(form_dict) + return {"application": payload} diff --git a/exporter/f680/services.py b/exporter/f680/services.py new file mode 100644 index 0000000000..a4c5405a4a --- /dev/null +++ b/exporter/f680/services.py @@ -0,0 +1,11 @@ +from core import client + + +def post_f680_application(request, json): + data = client.post(request, "/exporter/f680/application/", json) + return data.json(), data.status_code + + +def get_f680_application(request, application_id): + data = client.get(request, f"/exporter/f680/application/{application_id}/") + return data.json() diff --git a/exporter/f680/urls.py b/exporter/f680/urls.py new file mode 100644 index 0000000000..7608f92663 --- /dev/null +++ b/exporter/f680/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + + +app_name = "f680" + +urlpatterns = [ + path("apply/", views.F680ApplicationCreateView.as_view(), name="apply"), # PS-IGNORE + path("/apply/", views.F680ApplicationSummaryView.as_view(), name="summary"), # PS-IGNORE +] diff --git a/exporter/f680/views.py b/exporter/f680/views.py new file mode 100644 index 0000000000..ccb3c19828 --- /dev/null +++ b/exporter/f680/views.py @@ -0,0 +1,77 @@ +from http import HTTPStatus + +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import FormView + +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, # PS-IGNORE +) +from .services import ( + post_f680_application, # PS-IGNORE + get_f680_application, # PS-IGNORE +) + + +class F680ApplicationCreateView(LoginRequiredMixin, BaseSessionWizardView): # PS-IGNORE + form_list = [ + (ApplicationFormSteps.APPLICATION_NAME, ApplicationNameForm), + ] + + @expect_status( + HTTPStatus.CREATED, + "Error creating F680 application", # PS-IGNORE + "Unexpected error creating F680 application", # PS-IGNORE + ) + def post_f680_application(self, data): # PS-IGNORE + return post_f680_application(self.request, data) # PS-IGNORE + + def get_success_url(self, application_id): + return reverse( + "f680:summary", # PS-IGNORE + kwargs={ + "pk": application_id, + }, + ) + + def get_payload(self, form_dict): + return F680CreatePayloadBuilder().build(form_dict) # /PS-IGNORE + + def done(self, form_list, form_dict, **kwargs): + data = self.get_payload(form_dict) + + response_data, _ = self.post_f680_application(data) # PS-IGNORE + + return redirect(self.get_success_url(response_data["id"])) + + +class F680ApplicationSummaryView(LoginRequiredMixin, FormView): # PS-IGNORE + form_class = ApplicationSubmissionForm + template_name = "f680/summary.html" # PS-IGNORE + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + + self.application = get_f680_application(request, kwargs["pk"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["application"] = self.application + + return context + + # This method currently just redirects back to the app summary but this will eventually + # submit the clearnace application. + def get_success_url(self): + return reverse("f680:summary", kwargs={"pk": self.application["id"]}) # PS-IGNORE diff --git a/exporter/templates/f680/summary.html b/exporter/templates/f680/summary.html new file mode 100644 index 0000000000..374e2aa2ea --- /dev/null +++ b/exporter/templates/f680/summary.html @@ -0,0 +1,37 @@ +{% extends "layouts/base.html" %} + +{% load crispy_forms_tags %} + +{% block title %}Apply for an F680 Application{% endblock%} + +{% block body %} +
+
+

F680 Application

+
    +
  1. +

    + 1. + Create Application +

    +
      +
    • +
      + Your reference +
      + Saved +
      +
      +
      + {{ application.application.name }} +
      +
    • +
    +
  2. +
+
+ {% crispy form %} +
+
+
+{% endblock %} diff --git a/exporter/urls.py b/exporter/urls.py index f4b3387653..2cd4c43a67 100644 --- a/exporter/urls.py +++ b/exporter/urls.py @@ -53,3 +53,8 @@ urlpatterns = [ path("__debug__/", include(debug_toolbar.urls)), ] + urlpatterns + +if settings.FEATURE_FLAG_ALLOW_F680: + urlpatterns += [ + path("f680/", include("exporter.f680.urls")), + ] diff --git a/tests.caseworker.env b/tests.caseworker.env index 25212438a1..de57facf76 100644 --- a/tests.caseworker.env +++ b/tests.caseworker.env @@ -52,3 +52,4 @@ NOTIFY_FEEDBACK_EMAIL="feedback@lite" FILE_UPLOAD_HANDLERS="django.core.files.uploadhandler.MemoryFileUploadHandler,django.core.files.uploadhandler.TemporaryFileUploadHandler" SESSION_COOKIE_NAME=caseworker +FEATURE_FLAG_ALLOW_F680=True # PS-IGNORE diff --git a/tests.exporter.env b/tests.exporter.env index 99a370e97a..e10ebd401b 100644 --- a/tests.exporter.env +++ b/tests.exporter.env @@ -47,3 +47,4 @@ NOTIFY_FEEDBACK_EMAIL="feedback@lite" FILE_UPLOAD_HANDLERS="django.core.files.uploadhandler.MemoryFileUploadHandler,django.core.files.uploadhandler.TemporaryFileUploadHandler" SESSION_COOKIE_NAME=exporter +FEATURE_FLAG_ALLOW_F680=True # PS-IGNORE diff --git a/unit_tests/conftest.py b/unit_tests/conftest.py index 0f8dca8f0a..41512096df 100644 --- a/unit_tests/conftest.py +++ b/unit_tests/conftest.py @@ -555,6 +555,23 @@ 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 new file mode 100644 index 0000000000..5f139b57d0 --- /dev/null +++ b/unit_tests/exporter/applications/views/test_f680s.py @@ -0,0 +1,141 @@ +import pytest + +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 + + +@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(): # PS-IGNORE + return reverse("f680:apply") # PS-IGNORE + + +@pytest.fixture +def f680_summary_url_with_application(data_f680_case): # PS-IGNORE + return reverse("f680:summary", kwargs={"pk": data_f680_case["id"]}) # PS-IGNORE + + +@pytest.fixture +def post_to_step( + post_to_step_factory, + f680_apply_url, + mock_application_post, +): + return post_to_step_factory(f680_apply_url) # PS-IGNORE + + +@pytest.fixture +def mock_f680_application_get(requests_mock, data_f680_case): # PS-IGNORE + application_id = data_f680_case["id"] # PS-IGNORE + url = client._build_absolute_uri(f"/exporter/f680/application/{application_id}/") # PS-IGNORE + return requests_mock.get(url=url, json=data_f680_case) # PS-IGNORE + + +@pytest.fixture +def mock_application_post(requests_mock, data_f680_case): # PS-IGNORE + application = data_f680_case # PS-IGNORE + url = client._build_absolute_uri(f"/exporter/f680/application/") # PS-IGNORE + return requests_mock.post(url=url, json=application) + + +@pytest.fixture +def mock_application_post_with_application_id(requests_mock, data_f680_case): # PS-IGNORE + application = data_f680_case # PS-IGNORE + url = client._build_absolute_uri(f"/exporter/f680/application/{application['id']}/") # PS-IGNORE + return requests_mock.post(url=url, json=application) + + +@pytest.fixture(autouse=True) +def set_f680_feature_flag(settings): # PS-IGNORE + settings.FEATURE_FLAG_ALLOW_F680 = True # PS-IGNORE + + +def test_triage_f680_apply_redirect(authorized_client): # PS-IGNORE + response = authorized_client.post(reverse("apply_for_a_licence:f680_questions")) # PS-IGNORE + assert response.status_code == 302 + + +def test_apply_f680_view( + authorized_client, + f680_apply_url, # PS-IGNORE + mock_f680_application_get, # PS-IGNORE + post_to_step, + mock_application_post, + f680_summary_url_with_application, # PS-IGNORE + mock_application_post_with_application_id, +): + response = authorized_client.get(f680_apply_url) # PS-IGNORE + assert response.status_code == 200 + soup = BeautifulSoup(response.content, "html.parser") + assert "Name of the application" in soup.find("h1").text + assert isinstance(response.context["form"], ApplicationNameForm) + + response = post_to_step( + ApplicationFormSteps.APPLICATION_NAME, + {"application": {"name": "F680 Test 2"}}, # PS-IGNORE + ) + + assert response.status_code == 200 + response = authorized_client.post(f680_apply_url, {"application": {"name": "F680 Test 2"}}) + + assert response + + +def test_f680_summary_view_with_form( + f680_summary_url_with_application, + authorized_client, + mock_f680_application_get, + requests_mock, + data_f680_case, # PS-IGNORE + f680_apply_url, +): + + response = application_flow( + f680_summary_url_with_application, authorized_client, mock_f680_application_get # PS-IGNORE + ) + + assert response.status_code == 302 + assert response.url == f680_summary_url_with_application # PS-IGNORE + + +def application_flow( + f680_summary_url_with_application, # PS-IGNORE + authorized_client, + mock_f680_application_get, # PS-IGNORE +): + + response = authorized_client.get(f680_summary_url_with_application) # PS-IGNORE + assert not response.context["form"].errors + + 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" # PS-IGNORE + + response = authorized_client.post( + f680_summary_url_with_application, # PS-IGNORE + data={"application": {"name": "F680 Test 2"}}, # PS-IGNORE + ) + + return response + + +def test_f680_summary_view( + authorized_client, + f680_summary_url_with_application, # PS-IGNORE + mock_f680_application_get, # PS-IGNORE +): + response = authorized_client.get(f680_summary_url_with_application) # PS-IGNORE + assert response.status_code == 200 + assertTemplateUsed(response, "f680/summary.html") # PS-IGNORE 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 2727ea991b..fd9357c046 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 @@ -5,26 +5,29 @@ @pytest.mark.parametrize( - "value,expect_enabled,expect_disabled", + "siel_only_allowed, f680_allowed, expect_enabled, expect_disabled", ( - (True, ["export_licence"], ["transhipment", "trade_control_licence", "mod"]), - (False, ["export_licence", "transhipment", "trade_control_licence", "mod"], []), + (True, True, ["export_licence", "f680"], ["transhipment", "trade_control_licence"]), + (True, False, ["export_licence"], ["f680", "transhipment", "trade_control_licence"]), + (False, True, ["export_licence", "f680", "transhipment", "trade_control_licence"], []), + (False, False, ["export_licence", "transhipment", "trade_control_licence"], ["f680"]), ), ) -def test_opening_question_feature_flag(settings, value, expect_enabled, expect_disabled): +def test_opening_question_feature_flags(settings, siel_only_allowed, f680_allowed, expect_enabled, expect_disabled): # given the flag is set or unset - settings.FEATURE_FLAG_ONLY_ALLOW_SIEL = value + settings.FEATURE_FLAG_ONLY_ALLOW_SIEL = siel_only_allowed + settings.FEATURE_FLAG_ALLOW_F680 = f680_allowed # when the form is created form = triage_questions.opening_question() # then the disabled options reflect the feature flag - assert [item.key for item in form.questions[0].options if item.disabled] == expect_disabled 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( - "value,expect_enabled,expect_disabled", + "value, expect_enabled, expect_disabled", ( (True, [CaseTypes.SIEL], [CaseTypes.OGEL, CaseTypes.OIEL]), (False, [CaseTypes.SIEL, CaseTypes.OGEL, CaseTypes.OIEL], []), diff --git a/unit_tests/exporter/apply_for_a_licence/test_urls.py b/unit_tests/exporter/apply_for_a_licence/test_urls.py index 81ceb10982..3997423acc 100644 --- a/unit_tests/exporter/apply_for_a_licence/test_urls.py +++ b/unit_tests/exporter/apply_for_a_licence/test_urls.py @@ -8,7 +8,7 @@ from unit_tests.helpers import reload_urlconf -def test_url_respects_feature_flag_off(settings): +def test_url_respects_siel_only_feature_flag_off(settings): # given the feature is turned on settings.FEATURE_FLAG_ONLY_ALLOW_SIEL = True reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) @@ -22,12 +22,12 @@ def test_url_respects_feature_flag_off(settings): # but non SIEL urls are not found with pytest.raises(NoReverseMatch): reverse("apply_for_a_licence:transhipment_questions") - reverse("apply_for_a_licence:mod_questions") + # reverse("apply_for_a_licence:mod_questions") reverse("apply_for_a_licence:ogl_questions", kwargs={"ogl": "foo"}) reverse("apply_for_a_licence:ogl_submit", kwargs={"ogl": "foo", "pk": uuid4()}) -def test_url_respects_feature_flag_on(settings): +def test_url_respects_siel_only_feature_flag_on(settings): # given the feature is not turned on settings.FEATURE_FLAG_ONLY_ALLOW_SIEL = False reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) @@ -40,6 +40,21 @@ def test_url_respects_feature_flag_on(settings): # and non SIEL urls are not found reverse("apply_for_a_licence:transhipment_questions") - reverse("apply_for_a_licence:mod_questions") + # reverse("apply_for_a_licence:mod_questions") reverse("apply_for_a_licence:ogl_questions", kwargs={"ogl": "foo"}) reverse("apply_for_a_licence:ogl_submit", kwargs={"ogl": "foo", "pk": uuid4()}) + + +def test_url_respects_f680_feature_flag_on(settings): + settings.FEATURE_FLAG_ALLOW_F680 = True + reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) + + assert reverse("apply_for_a_licence:f680_questions") == "/apply-for-a-licence/f680/" + + +def test_url_respects_f680_feature_flag_off(settings): + settings.FEATURE_FLAG_ALLOW_F680 = False + reload_urlconf(["exporter.apply_for_a_licence.urls", settings.ROOT_URLCONF]) + + with pytest.raises(NoReverseMatch): + reverse("apply_for_a_licence:f680_questions")