diff --git a/caseworker/f680/recommendation/forms/forms.py b/caseworker/f680/recommendation/forms/forms.py new file mode 100644 index 000000000..067ebdba4 --- /dev/null +++ b/caseworker/f680/recommendation/forms/forms.py @@ -0,0 +1,181 @@ +from django import forms + +from crispy_forms_gds.choices import Choice +from crispy_forms_gds.helper import FormHelper +from crispy_forms_gds.layout import Submit + +from core.common.forms import BaseForm +from core.forms.layouts import ( + ConditionalCheckboxes, + ConditionalCheckboxesQuestion, + RadioTextArea, +) + + +class SelectRecommendationTypeForm(forms.Form): + CHOICES = [ + ("approve_all", "Approve all"), + ] + + recommendation = forms.ChoiceField( + choices=CHOICES, + widget=forms.RadioSelect, + label="", + error_messages={"required": "Select if you approve all or refuse all"}, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.add_input(Submit("submit", "Continue")) + + +class PicklistAdviceForm(forms.Form): + def _picklist_to_choices(self, picklist_data, include_other=True): + reasons_choices = [] + reasons_text = {} + + for result in picklist_data["results"]: + key = "_".join(result.get("name").lower().split()) + choice = Choice(key, result.get("name")) + if result == picklist_data["results"][-1]: + choice = Choice(key, result.get("name"), divider="or") + reasons_choices.append(choice) + reasons_text[key] = result.get("text") + picklist_choices = len(reasons_choices) > 0 + if include_other and picklist_choices: + reasons_text["other"] = "" + reasons_choices.append(Choice("other", "Other")) + return reasons_choices, reasons_text + + +class RecommendAnApprovalForm(PicklistAdviceForm, BaseForm): + class Layout: + TITLE = "Recommend an approval" + + approval_reasons = forms.CharField( + widget=forms.Textarea(attrs={"rows": 7, "class": "govuk-!-margin-top-4", "name": "approval_reasons"}), + label="", + error_messages={"required": "Enter a reason for approving"}, + ) + approval_radios = forms.ChoiceField( + label="What is your reason for approving?", + required=False, + widget=forms.RadioSelect, + choices=(), + ) + add_licence_conditions = forms.BooleanField( + label="Add licence conditions, instructions to exporter or footnotes (optional)", + required=False, + ) + + def __init__(self, *args, **kwargs): + approval_reason = kwargs.pop("approval_reason") + # this follows the same pattern as denial_reasons. + approval_choices, approval_text = self._picklist_to_choices(approval_reason) + self.approval_text = approval_text + super().__init__(*args, **kwargs) + self.fields["approval_radios"].choices = approval_choices + + def get_layout_fields(self): + return ( + RadioTextArea("approval_radios", "approval_reasons", self.approval_text), + "add_licence_conditions", + ) + + +class SimpleLicenceConditionsForm(BaseForm): + class Layout: + TITLE = "Add licence conditions (optional)" + + proviso = forms.CharField( + widget=forms.Textarea(attrs={"rows": 7}), + label="Licence condition", + required=False, + ) + + def get_layout_fields(self): + return ("proviso",) + + +class PicklistLicenceConditionsForm(PicklistAdviceForm, BaseForm): + class Layout: + TITLE = "Add licence conditions (optional)" + + proviso_checkboxes = forms.MultipleChoiceField( + label="", + required=False, + widget=forms.CheckboxSelectMultiple, + choices=(), + ) + + def clean(self): + cleaned_data = super().clean() + # only return proviso (text) for selected checkboxes, nothing else matters, join by 2 newlines + return { + "proviso": "\n\n--------\n".join( + [cleaned_data[selected] for selected in cleaned_data["proviso_checkboxes"]] + ) + } + + def __init__(self, *args, **kwargs): + proviso = kwargs.pop("proviso") + + proviso_choices, proviso_text = self._picklist_to_choices(proviso) + + self.conditional_checkbox_choices = ( + ConditionalCheckboxesQuestion(choices.label, choices.value) for choices in proviso_choices + ) + + super().__init__(*args, **kwargs) + + self.fields["proviso_checkboxes"].choices = proviso_choices + for choices in proviso_choices: + self.fields[choices.value] = forms.CharField( + widget=forms.Textarea(attrs={"rows": 3}), + label="Description", + required=False, + initial=proviso_text[choices.value], + ) + + def get_layout_fields(self): + return (ConditionalCheckboxes("proviso_checkboxes", *self.conditional_checkbox_choices),) + + +class FootnotesApprovalAdviceForm(PicklistAdviceForm, BaseForm): + class Layout: + TITLE = "Add instructions to the exporter, or a reporting footnote (optional)" + + instructions_to_exporter = forms.CharField( + widget=forms.Textarea(attrs={"rows": "3"}), + label="Add any instructions for the exporter (optional)", + help_text="These may be added to the licence cover letter, subject to review by the Licensing Unit.", + required=False, + ) + + footnote_details_radios = forms.ChoiceField( + label="Add a reporting footnote (optional)", + required=False, + widget=forms.RadioSelect, + choices=(), + ) + footnote_details = forms.CharField( + widget=forms.Textarea(attrs={"rows": 3}), + label="", + required=False, + ) + + def __init__(self, *args, **kwargs): + footnote_details = kwargs.pop("footnote_details") + footnote_details_choices, footnote_text = self._picklist_to_choices(footnote_details) + self.footnote_text = footnote_text + + super().__init__(*args, **kwargs) + + self.fields["footnote_details_radios"].choices = footnote_details_choices + + def get_layout_fields(self): + return ( + "instructions_to_exporter", + RadioTextArea("footnote_details_radios", "footnote_details", self.footnote_text), + ) diff --git a/caseworker/f680/recommendation/mixins.py b/caseworker/f680/recommendation/mixins.py new file mode 100644 index 000000000..4c3bb3ee2 --- /dev/null +++ b/caseworker/f680/recommendation/mixins.py @@ -0,0 +1,42 @@ +from django.utils.functional import cached_property + +from caseworker.cases.services import get_case +from caseworker.users.services import get_gov_user + + +class CaseContextMixin: + """Most advice views need a reference to the associated + Case object. This mixin, injects a reference to the Case + in the context. + """ + + @property + def case_id(self): + return str(self.kwargs["pk"]) + + @cached_property + def case(self): + return get_case(self.request, self.case_id) + + @property + def caseworker_id(self): + return str(self.request.session["lite_api_user_id"]) + + @property + def caseworker(self): + data, _ = get_gov_user(self.request, self.caseworker_id) + return data["user"] + + def get_context(self, **kwargs): + return {} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + return { + **context, + **self.get_context(case=self.case), + "case": self.case, + "queue_pk": self.kwargs["queue_pk"], + "caseworker": self.caseworker, + } diff --git a/caseworker/f680/recommendation/services.py b/caseworker/f680/recommendation/services.py new file mode 100644 index 000000000..b2dfbdd84 --- /dev/null +++ b/caseworker/f680/recommendation/services.py @@ -0,0 +1,54 @@ +from collections import defaultdict + +from caseworker.advice import constants +from core import client + + +def filter_current_user_recommendation(all_recommendation, user_id): + return [ + recommendation + for recommendation in all_recommendation + if recommendation["level"] == constants.AdviceLevel.USER + and recommendation["type"]["key"] in ["approve", "proviso", "refuse"] + and (recommendation["user"]["id"] == user_id) + ] + + +def filter_recommendation_by_level(all_recommendation, recommendation_levels): + return [recommendation for recommendation in all_recommendation if recommendation["level"] in recommendation_levels] + + +def filter_recommendation_by_team(all_recommendation, team_alias): + return [recommendation for recommendation in all_recommendation if recommendation["team"]["alias"] == team_alias] + + +def group_recommendation_by_user(recommendation): + result = defaultdict(list) + for item in recommendation: + result[item["user"]["id"]].append(item) + return result + + +def get_current_user_recommendation(recommendation, caseworker, team_alias): + user_level_recommendation = filter_recommendation_by_level(recommendation, ["user"]) + user_recommendation = filter_current_user_recommendation(user_level_recommendation, caseworker) + user_recommendation = filter_recommendation_by_team(user_recommendation, team_alias) + grouped_user_recommendation = group_recommendation_by_user(user_recommendation) + return grouped_user_recommendation + + +def post_approval_recommendation(request, case, data, level="user-advice"): + json = [ + { + "type": "proviso" if data.get("proviso", False) else "approve", + "text": data["approval_reasons"], + "proviso": data.get("proviso", ""), + "note": data.get("instructions_to_exporter", ""), + "footnote_required": True if data.get("footnote_details") else False, + "footnote": data.get("footnote_details", ""), + "denial_reasons": [], + } + ] + response = client.post(request, f"/cases/{case['id']}/{level}/", json) + response.raise_for_status() + return response.json(), response.status_code diff --git a/caseworker/f680/recommendation/views.py b/caseworker/f680/recommendation/views.py new file mode 100644 index 000000000..1ee9ea340 --- /dev/null +++ b/caseworker/f680/recommendation/views.py @@ -0,0 +1,133 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import FormView, TemplateView +from http import HTTPStatus + +from core.auth.views import LoginRequiredMixin + +from caseworker.advice.constants import AdviceSteps +from caseworker.advice.conditionals import form_add_licence_conditions +from caseworker.advice.payloads import GiveApprovalAdvicePayloadBuilder +from caseworker.advice.picklist_helpers import approval_picklist, footnote_picklist, proviso_picklist +from caseworker.cases.services import get_case +from caseworker.cases.helpers.case import CaseworkerMixin +from caseworker.f680.recommendation.forms.forms import ( + FootnotesApprovalAdviceForm, + PicklistLicenceConditionsForm, + RecommendAnApprovalForm, + SelectRecommendationTypeForm, + SimpleLicenceConditionsForm, +) +from caseworker.f680.recommendation.mixins import CaseContextMixin +from caseworker.f680.recommendation.services import get_current_user_recommendation, post_approval_recommendation +from caseworker.queues.services import get_queue +from core.decorators import expect_status +from core.wizard.conditionals import C +from core.wizard.views import BaseSessionWizardView + + +class CaseRecommendationView(LoginRequiredMixin, CaseworkerMixin, TemplateView): + template_name = "f680/case/recommendation/recommendation.html" + + def setup(self, request, *args, **kwargs): + super().setup(request, *args, **kwargs) + + self.case_id = str(kwargs["pk"]) + self.case = get_case(request, self.case_id) + self.queue_id = kwargs["queue_pk"] + self.queue = get_queue(request, self.queue_id) + + def get_context_data(self, **kwargs): + context_data = super().get_context_data(**kwargs) + context_data["case"] = self.case + return context_data + + +class MyRecommendationView(LoginRequiredMixin, CaseContextMixin, TemplateView): + template_name = "f680/case/recommendation/view_my_recommendation.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + title = f"View recommendation for this case - {self.case.reference_code} - {self.case.organisation['name']}" + recommendation = get_current_user_recommendation( + self.case.advice, self.caseworker_id, self.caseworker["team"]["alias"] + ) + recommendation = recommendation.get(self.caseworker_id) + + return { + **context, + "title": title, + "recommendation": recommendation[0] if recommendation else None, + } + + +class SelectRecommendationTypeView(LoginRequiredMixin, CaseContextMixin, FormView): + template_name = "f680/case/recommendation/select_recommendation_type.html" + form_class = SelectRecommendationTypeForm + + def get_success_url(self): + return reverse("cases:f680:approve_all", kwargs=self.kwargs) + + def form_valid(self, form): + self.recommendation = form.cleaned_data["recommendation"] + return super().form_valid(form) + + +class BaseApprovalRecommendationView(LoginRequiredMixin, CaseContextMixin, BaseSessionWizardView): + template_name = "f680/case/recommendation/form_wizard.html" + + condition_dict = { + AdviceSteps.LICENCE_CONDITIONS: C(form_add_licence_conditions(AdviceSteps.RECOMMEND_APPROVAL)), + AdviceSteps.LICENCE_FOOTNOTES: C(form_add_licence_conditions(AdviceSteps.RECOMMEND_APPROVAL)), + } + + form_list = [ + (AdviceSteps.RECOMMEND_APPROVAL, RecommendAnApprovalForm), + (AdviceSteps.LICENCE_CONDITIONS, PicklistLicenceConditionsForm), + (AdviceSteps.LICENCE_FOOTNOTES, FootnotesApprovalAdviceForm), + ] + + step_kwargs = { + AdviceSteps.RECOMMEND_APPROVAL: approval_picklist, + AdviceSteps.LICENCE_CONDITIONS: proviso_picklist, + AdviceSteps.LICENCE_FOOTNOTES: footnote_picklist, + } + + def get_success_url(self): + return reverse("cases:f680:view_my_recommendation", kwargs=self.kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["back_link_url"] = reverse("cases:f680:select_recommendation_type", kwargs=self.kwargs) + return context + + @expect_status( + HTTPStatus.CREATED, + "Error adding approval recommendation", + "Unexpected error adding approval recommendation", + ) + def post_approval_recommendation(self, data): + return post_approval_recommendation(self.request, self.case, data) + + def get_payload(self, form_dict): + return GiveApprovalAdvicePayloadBuilder().build(form_dict) + + def done(self, form_list, form_dict, **kwargs): + data = self.get_payload(form_dict) + self.post_approval_recommendation(data) + return redirect(self.get_success_url()) + + +class GiveApprovalRecommendationView(BaseApprovalRecommendationView): + + def get_form(self, step=None, data=None, files=None): + + if step == AdviceSteps.LICENCE_CONDITIONS: + picklist_form_kwargs = self.step_kwargs[AdviceSteps.LICENCE_CONDITIONS](self) + picklist_options_exist = len(picklist_form_kwargs["proviso"]["results"]) > 0 + if picklist_options_exist: + return PicklistLicenceConditionsForm(data=data, prefix=step, **picklist_form_kwargs) + else: + return SimpleLicenceConditionsForm(data=data, prefix=step) + + return super().get_form(step, data, files) diff --git a/caseworker/f680/templates/f680/case/recommendation/form_wizard.html b/caseworker/f680/templates/f680/case/recommendation/form_wizard.html new file mode 100644 index 000000000..83f3744a3 --- /dev/null +++ b/caseworker/f680/templates/f680/case/recommendation/form_wizard.html @@ -0,0 +1 @@ +{% extends 'core/form-wizard.html' %} diff --git a/caseworker/f680/templates/f680/case/recommendation/recommendation.html b/caseworker/f680/templates/f680/case/recommendation/recommendation.html new file mode 100644 index 000000000..5aff0a715 --- /dev/null +++ b/caseworker/f680/templates/f680/case/recommendation/recommendation.html @@ -0,0 +1,9 @@ +{% extends 'layouts/case.html' %} + + +{% block details %} + +
+Make recommendation + +{% endblock %} diff --git a/caseworker/f680/templates/f680/case/recommendation/recommendation_details.html b/caseworker/f680/templates/f680/case/recommendation/recommendation_details.html new file mode 100644 index 000000000..2dc1edeac --- /dev/null +++ b/caseworker/f680/templates/f680/case/recommendation/recommendation_details.html @@ -0,0 +1,60 @@ +{% load advice_tags %} +{% load static custom_tags %} +{% with user=recommendation.user decision=recommendation.type.key %} + + {% if decision == 'approve' or decision == 'proviso' %} +

+ {% if team %} Approved by {{ recommendation.team.name }} {% else %} Approved by {{ user|full_name }} {% endif %} + {{ recommendation.created_at|parse_date|date:"d F Y" }} +

+ {% elif decision == 'refuse' %} +

+ {% if team %} Refused by {{ recommendation.team.name }} {% else %} Refused by {{ user|full_name }} {% endif %} +

+ {% endif %} +
+
+
+ {% if decision == 'approve' or decision == 'proviso' %} +
+
+
+

Reason for approving

+

{{ recommendation.text|linebreaks }}

+ + {% if recommendation.proviso %} +

Licence condition

+
{{ recommendation.proviso|linebreaks }}
+ {% endif %} + + {% if recommendation.note %} +

Additional instructions

+

{{ recommendation.note|linebreaks }}

+ {% endif %} + + {% if recommendation.footnote %} +

Reporting footnote

+

{{ recommendation.footnote|linebreaks }}

+ {% endif %} +
+
+
+ {% elif decision == 'refuse' %} +
+
+
+ {% if refusal_note %} +

Refusal meeting note

+

{{ refusal_note.0.text|linebreaks }}

+ {% else %} +

Reason for refusing

+

{{ recommendation.text|linebreaks }}

+ {% endif %} + +
+
+
+ {% endif %} +
+
+{% endwith %} diff --git a/caseworker/f680/templates/f680/case/recommendation/select_recommendation_type.html b/caseworker/f680/templates/f680/case/recommendation/select_recommendation_type.html new file mode 100644 index 000000000..30247a572 --- /dev/null +++ b/caseworker/f680/templates/f680/case/recommendation/select_recommendation_type.html @@ -0,0 +1,16 @@ +{% extends 'layouts/case.html' %} +{% load crispy_forms_tags %} + +{% block body %} +
+ Back +
+
+
+

What is your recommendation?

+ {% crispy form %} +
+
+
+
+{% endblock %} diff --git a/caseworker/f680/templates/f680/case/recommendation/view_my_recommendation.html b/caseworker/f680/templates/f680/case/recommendation/view_my_recommendation.html new file mode 100644 index 000000000..90cc8ab91 --- /dev/null +++ b/caseworker/f680/templates/f680/case/recommendation/view_my_recommendation.html @@ -0,0 +1,35 @@ +{% extends 'layouts/case.html' %} + +{% block title %} {{ title }} {% endblock %} + +{% block body %} +
+
+ {% for error in form.non_field_errors %} + + {% endfor %} +
+
+
+

View recommendation

+ {% if recommendation %} + {% include "f680/case/recommendation/recommendation_details.html" %} + {% else %} + + {% endif %} +
+
+
+
+{% endblock %} diff --git a/caseworker/f680/tests/conftest.py b/caseworker/f680/tests/conftest.py new file mode 100644 index 000000000..90a2b18f3 --- /dev/null +++ b/caseworker/f680/tests/conftest.py @@ -0,0 +1,107 @@ +import pytest + +from datetime import timedelta + +from django.utils import timezone + +from core import client + + +@pytest.fixture +def data_f680_case(f680_case_id, f680_reference_code): + submitted_at = timezone.now() - timedelta(days=7) + return { + "case": { + "advice": [], + "all_flags": [], + "amendment_of": None, + "assigned_users": {}, + "case_officer": None, + "case_type": { + "id": "00000000-0000-0000-0000-000000000007", + "reference": {"key": "f680", "value": "MOD F680 Clearance"}, + "sub_type": {"key": "f680_clearance", "value": "MOD F680 Clearance"}, + "type": {"key": "application", "value": "Application"}, + }, + "copy_of": None, + "countersign_advice": [], + "data": { + "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 + "name": "Parrish, Crosby and Friedman", + "status": "active", + "type": "commercial", + }, + "reference_code": f680_reference_code, + "status": {"id": "00000000-0000-0000-0000-000000000001", "key": "submitted", "value": "Submitted"}, + "submitted_at": submitted_at.isoformat(), + "submitted_by": None, + }, + "flags": [], + "has_advice": {"final": False, "my_team": False, "my_user": False, "team": False, "user": False}, + "id": f680_case_id, + "latest_activity": None, + "licences": [], + "queue_details": [], + "queue_names": [], + "queues": [], + "reference_code": "F680/2025/0000016", + "sla_days": 0, + "sla_remaining_days": None, + "submitted_at": submitted_at.isoformat(), + "superseded_by": None, + } + } + + +@pytest.fixture +def f680_case_id(): + return "67271217-7e55-4345-9db4-31de1bfe4067" + + +@pytest.fixture +def f680_reference_code(): + return "F680/2025/0000016" + + +@pytest.fixture +def mock_f680_case(f680_case_id, requests_mock, data_f680_case): + url = client._build_absolute_uri(f"/cases/{f680_case_id}/") + return requests_mock.get(url=url, json=data_f680_case) + + +@pytest.fixture +def mock_post_recommendation(requests_mock, data_f680_case): + user_recommendation_create_url = f"/cases/{data_f680_case['case']['id']}/user-advice/" + return requests_mock.post(user_recommendation_create_url, json={}, status_code=201) + + +@pytest.fixture +def mock_proviso_no_results(requests_mock): + url = client._build_absolute_uri("/picklist/?type=proviso&page=1&disable_pagination=True&show_deactivated=False") + return requests_mock.get(url=url, json={"results": []}) diff --git a/caseworker/f680/tests/recommendation/__init__.py b/caseworker/f680/tests/recommendation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/caseworker/f680/tests/recommendation/test_views.py b/caseworker/f680/tests/recommendation/test_views.py new file mode 100644 index 000000000..4ecf6718a --- /dev/null +++ b/caseworker/f680/tests/recommendation/test_views.py @@ -0,0 +1,280 @@ +import pytest + +from bs4 import BeautifulSoup +from pytest_django.asserts import assertTemplateUsed + +from django.urls import reverse + +from caseworker.advice.constants import AdviceSteps +from caseworker.f680.recommendation.forms.forms import ( + FootnotesApprovalAdviceForm, + PicklistLicenceConditionsForm, + RecommendAnApprovalForm, + SelectRecommendationTypeForm, + SimpleLicenceConditionsForm, +) +from core import client + + +@pytest.fixture(autouse=True) +def setup( + mock_queue, + mock_case, + mock_approval_reason, + mock_denial_reasons, + mock_footnote_details, +): + return + + +@pytest.fixture +def url_approve(data_queue, f680_case_id): + return reverse("cases:f680:approve_all", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id}) + + +@pytest.fixture +def view_recommendation_url(data_queue, f680_case_id): + return reverse("cases:f680:view_my_recommendation", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id}) + + +@pytest.fixture +def post_to_step(post_to_step_factory, url_approve): + return post_to_step_factory(url_approve) + + +@pytest.fixture +def mock_current_gov_user(requests_mock, f680_case_id): + return requests_mock.get( + client._build_absolute_uri(f"/gov_users/{f680_case_id}"), + json={"user": {"id": "58e62718-e889-4a01-b603-e676b794b394"}}, + ) + + +@pytest.fixture +def recommendation(current_user, admin_team): + return [ + { + "created_at": "2021-10-16T23:48:39.486679+01:00", + "denial_reasons": [], + "id": "429c5596-fe8b-4540-988b-c37805cd08de", + "level": "user", + "note": "additional notes", + "text": "No concerns", + "type": {"key": "approve", "value": "Approve"}, + "user": current_user, + "team": admin_team, + } + ] + + +class TestF680RecommendationView: + + def test_GET_recommendation_success( + self, authorized_client, data_queue, mock_f680_case, f680_case_id, f680_reference_code, data_f680_case + ): + url = reverse("cases:f680:recommendation", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id}) + response = authorized_client.get(url) + assert response.status_code == 200 + assertTemplateUsed(response, "f680/case/recommendation/recommendation.html") + + assert dict(response.context["case"]) == data_f680_case["case"] + soup = BeautifulSoup(response.content, "html.parser") + assert f680_reference_code in soup.find("h1").text + make_recommendation_button = soup.find(id="make-recommendation-button") + assert make_recommendation_button + assert ( + make_recommendation_button["href"] + == f'/queues/{data_queue["id"]}/cases/{f680_case_id}/f680/recommendation/select-recommendation-type/' + ) + + +class TestF680SelectRecommendationTypeView: + + def test_GET_select_recommendation_type( + self, authorized_client, data_queue, mock_f680_case, f680_case_id, f680_reference_code, data_f680_case + ): + url = reverse( + "cases:f680:select_recommendation_type", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id} + ) + response = authorized_client.get(url) + assert response.status_code == 200 + assertTemplateUsed(response, "f680/case/recommendation/select_recommendation_type.html") + assert dict(response.context["case"]) == data_f680_case["case"] + + form = response.context["form"] + assert isinstance(form, SelectRecommendationTypeForm) + assert form.fields["recommendation"].choices == [("approve_all", "Approve all")] + + @pytest.mark.parametrize("recommendation, redirect", [("approve_all", "approve-all")]) + def test_submit_select_recommendation_type( + self, + authorized_client, + data_queue, + mock_f680_case, + f680_case_id, + f680_reference_code, + data_f680_case, + recommendation, + redirect, + ): + url = reverse( + "cases:f680:select_recommendation_type", kwargs={"queue_pk": data_queue["id"], "pk": f680_case_id} + ) + response = authorized_client.post(url, data={"recommendation": recommendation}) + assert response.status_code == 302 + assert ( + response.url + == f'/queues/00000000-0000-0000-0000-000000000001/cases/{data_f680_case["case"]["id"]}/f680/recommendation/{redirect}/' + ) + + +class TestF680GiveApprovalRecommendationView: + + def test_give_approval_advice_get(self, authorized_client, beautiful_soup, mock_f680_case, url_approve): + response = authorized_client.get(url_approve) + assert response.status_code == 200 + assertTemplateUsed(response, "f680/case/recommendation/form_wizard.html") + + soup = beautiful_soup(response.content) + header = soup.find("h1", {"class": "govuk-heading-xl"}) + assert header.text == "Recommend an approval" + + form = response.context["form"] + assert isinstance(form, RecommendAnApprovalForm) + assert list(form.fields.keys()) == ["approval_reasons", "approval_radios", "add_licence_conditions"] + + def test_approval_advice_post_valid( + self, + authorized_client, + data_f680_case, + url_approve, + mock_f680_case, + mock_approval_reason, + mock_proviso, + mock_footnote_details, + mock_post_recommendation, + post_to_step, + beautiful_soup, + ): + response = post_to_step( + AdviceSteps.RECOMMEND_APPROVAL, + {"approval_reasons": "No concerns"}, + ) + assert response.status_code == 302 + + def test_approval_advice_post_valid_add_conditional( + self, + authorized_client, + data_queue, + f680_case_id, + data_f680_case, + url_approve, + mock_f680_case, + mock_approval_reason, + mock_proviso, + mock_footnote_details, + mock_post_recommendation, + post_to_step, + beautiful_soup, + ): + response = post_to_step( + AdviceSteps.RECOMMEND_APPROVAL, + {"approval_reasons": "reason", "add_licence_conditions": True}, + ) + assert response.status_code == 200 + + soup = beautiful_soup(response.content) + # redirected to next form + form = response.context["form"] + assert isinstance(form, PicklistLicenceConditionsForm) + + header = soup.find("h1") + assert header.text == "Add licence conditions (optional)" + + add_LC_response = post_to_step(AdviceSteps.LICENCE_CONDITIONS, {"proviso": "proviso"}) + assert add_LC_response.status_code == 200 + soup = beautiful_soup(add_LC_response.content) + # redirected to next form + form = add_LC_response.context["form"] + assert isinstance(form, FootnotesApprovalAdviceForm) + header = soup.find("h1") + assert header.text == "Add instructions to the exporter, or a reporting footnote (optional)" + + add_instructions_response = post_to_step( + AdviceSteps.LICENCE_FOOTNOTES, + {"instructions_to_exporter": "instructions", "footnote_details": "footnotes"}, + ) + assert add_instructions_response.status_code == 302 + assert ( + add_instructions_response.url + == f'/queues/{data_queue["id"]}/cases/{f680_case_id}/f680/recommendation/view-my-recommendation/' + ) + + def test_approval_advice_post_valid_add_conditional_optional( + self, + authorized_client, + data_queue, + f680_case_id, + data_f680_case, + url_approve, + mock_f680_case, + mock_approval_reason, + mock_footnote_details, + mock_post_recommendation, + mock_proviso_no_results, + post_to_step, + beautiful_soup, + ): + response = post_to_step( + AdviceSteps.RECOMMEND_APPROVAL, + {"approval_reasons": "reason", "add_licence_conditions": True}, + ) + assert response.status_code == 200 + + soup = beautiful_soup(response.content) + # redirected to next form + form = response.context["form"] + assert isinstance(form, SimpleLicenceConditionsForm) + + header = soup.find("h1") + assert header.text == "Add licence conditions (optional)" + + add_LC_response = post_to_step(AdviceSteps.LICENCE_CONDITIONS, {}) + assert add_LC_response.status_code == 200 + soup = beautiful_soup(add_LC_response.content) + # redirected to next form + form = add_LC_response.context["form"] + assert isinstance(form, FootnotesApprovalAdviceForm) + header = soup.find("h1") + assert header.text == "Add instructions to the exporter, or a reporting footnote (optional)" + + add_instructions_response = post_to_step( + AdviceSteps.LICENCE_FOOTNOTES, + {"instructions_to_exporter": "instructions", "footnote_details": "footnotes"}, + ) + assert add_instructions_response.status_code == 302 + assert ( + add_instructions_response.url + == f'/queues/{data_queue["id"]}/cases/{f680_case_id}/f680/recommendation/view-my-recommendation/' + ) + + +class TestF680MyRecommendationView: + def test_view_approve_recommendation( + self, + authorized_client, + data_f680_case, + mock_f680_case, + mock_current_gov_user, + recommendation, + view_recommendation_url, + ): + data_f680_case["case"]["advice"] = recommendation + response = authorized_client.get(view_recommendation_url) + assert response.status_code == 200 + assertTemplateUsed(response, "f680/case/recommendation/view_my_recommendation.html") + + soup = BeautifulSoup(response.content, "html.parser") + assert soup.find("h1", {"class": "govuk-heading-xl"}).text == "View recommendation" + assert soup.find("h2", {"class": "govuk-heading-m"}).text == "Reason for approving" + assert soup.find("p", {"class": "govuk-body"}).text == "No concerns" diff --git a/caseworker/f680/tests/test_views.py b/caseworker/f680/tests/test_views.py index 4c6cb1f39..ebd97b4a7 100644 --- a/caseworker/f680/tests/test_views.py +++ b/caseworker/f680/tests/test_views.py @@ -1,10 +1,9 @@ import pytest -from datetime import timedelta + from requests.exceptions import HTTPError from bs4 import BeautifulSoup from django.urls import reverse -from django.utils import timezone from core import client @@ -34,94 +33,6 @@ def mock_missing_case(missing_case_id, requests_mock): return requests_mock.get(url=url, status_code=404) -@pytest.fixture -def f680_case_id(): - return "67271217-7e55-4345-9db4-31de1bfe4067" - - -@pytest.fixture -def f680_reference_code(): - return "F680/2025/0000016" - - -@pytest.fixture -def data_f680_case(f680_case_id, f680_reference_code): - submitted_at = timezone.now() - timedelta(days=7) - return { - "case": { - "advice": [], - "all_flags": [], - "amendment_of": None, - "assigned_users": {}, - "case_officer": None, - "case_type": { - "id": "00000000-0000-0000-0000-000000000007", - "reference": {"key": "f680", "value": "MOD F680 Clearance"}, - "sub_type": {"key": "f680_clearance", "value": "MOD F680 Clearance"}, - "type": {"key": "application", "value": "Application"}, - }, - "copy_of": None, - "countersign_advice": [], - "data": { - "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 - "name": "Parrish, Crosby and Friedman", - "status": "active", - "type": "commercial", - }, - "reference_code": f680_reference_code, - "status": {"id": "00000000-0000-0000-0000-000000000001", "key": "submitted", "value": "Submitted"}, - "submitted_at": submitted_at.isoformat(), - "submitted_by": None, - }, - "flags": [], - "has_advice": {"final": False, "my_team": False, "my_user": False, "team": False, "user": False}, - "id": f680_case_id, - "latest_activity": None, - "licences": [], - "queue_details": [], - "queue_names": [], - "queues": [], - "reference_code": "F680/2025/0000016", - "sla_days": 0, - "sla_remaining_days": None, - "submitted_at": submitted_at.isoformat(), - "superseded_by": None, - } - } - - -@pytest.fixture -def mock_f680_case(f680_case_id, requests_mock, data_f680_case): - url = client._build_absolute_uri(f"/cases/{f680_case_id}/") - 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"} diff --git a/caseworker/f680/urls.py b/caseworker/f680/urls.py index 0bd3e3876..d86357dc0 100644 --- a/caseworker/f680/urls.py +++ b/caseworker/f680/urls.py @@ -1,6 +1,7 @@ from django.urls import path from caseworker.f680 import views +from caseworker.f680.recommendation import views as recommendation_views app_name = "f680" @@ -11,6 +12,24 @@ views.CaseDetailView.as_view(), name="details", ), + path( + "recommendation/", + recommendation_views.CaseRecommendationView.as_view(), + name="recommendation", + ), + path( + "recommendation/select-recommendation-type/", + recommendation_views.SelectRecommendationTypeView.as_view(), + name="select_recommendation_type", + ), + path( + "recommendation/view-my-recommendation/", + recommendation_views.MyRecommendationView.as_view(), + name="view_my_recommendation", + ), + path( + "recommendation/approve-all/", recommendation_views.GiveApprovalRecommendationView.as_view(), name="approve_all" + ), path( "move-case-forward", views.MoveCaseForward.as_view(),