diff --git a/api/cases/migrations/0073_licencedecision_denial_reasons.py b/api/cases/migrations/0073_licencedecision_denial_reasons.py new file mode 100644 index 0000000000..f04f1800dd --- /dev/null +++ b/api/cases/migrations/0073_licencedecision_denial_reasons.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-28 11:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("denial_reasons", "0006_populate_uuid_field"), + ("cases", "0072_alter_decision_populate_issued_on_appeal"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="denial_reasons", + field=models.ManyToManyField(to="denial_reasons.denialreason"), + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index 309e410757..48765c8c05 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -342,7 +342,7 @@ def no_licence_required(self): notify_exporter_no_licence_required(self) @transaction.atomic - def finalise(self, request, decisions): + def finalise(self, user, decisions, note): from api.audit_trail import service as audit_trail_service from api.cases.libraries.finalise import remove_flags_on_finalisation, remove_flags_from_audit_trail from api.licences.models import Licence @@ -361,7 +361,7 @@ def finalise(self, request, decisions): if Licence.objects.filter(case=self).count() > 1: audit_trail_service.create( - actor=request.user, + actor=user, verb=AuditType.REINSTATED_APPLICATION, target=self, payload={ @@ -376,12 +376,12 @@ def finalise(self, request, decisions): self.save() audit_trail_service.create( - actor=request.user, + actor=user, verb=AuditType.UPDATED_STATUS, target=self, payload={ "status": {"new": self.status.status, "old": old_status}, - "additional_text": request.data.get("note"), + "additional_text": note, }, ) logging.info("Case is now finalised") @@ -410,16 +410,27 @@ def finalise(self, request, decisions): if previous_licence_decision.decision == current_decision: previous_decision = previous_licence_decision - LicenceDecision.objects.create( + licence_decision = LicenceDecision.objects.create( case=self, decision=current_decision, licence=licence, previous_decision=previous_decision, ) + if advice_type == AdviceType.REFUSE: + denial_reasons = ( + self.advice.filter( + level=AdviceLevel.FINAL, + type=AdviceType.REFUSE, + ) + .only("denial_reasons__id") + .distinct() + .values_list("denial_reasons__id", flat=True) + ) + licence_decision.denial_reasons.set(denial_reasons) licence_reference = licence.reference_code if licence and advice_type == AdviceType.APPROVE else "" audit_trail_service.create( - actor=request.user, + actor=user, verb=AuditType.CREATED_FINAL_RECOMMENDATION, target=self, payload={ @@ -846,6 +857,7 @@ class LicenceDecision(TimestampableModel): licence = models.ForeignKey( "licences.Licence", on_delete=models.DO_NOTHING, related_name="licence_decisions", null=True, blank=True ) + denial_reasons = models.ManyToManyField(DenialReason) excluded_from_statistics_reason = models.TextField(default=None, blank=True, null=True) previous_decision = models.ForeignKey( "self", related_name="previous_decisions", default=None, null=True, on_delete=models.DO_NOTHING diff --git a/api/cases/tests/test_finalise_advice.py b/api/cases/tests/test_finalise_advice.py index c342705acc..c2335233ea 100644 --- a/api/cases/tests/test_finalise_advice.py +++ b/api/cases/tests/test_finalise_advice.py @@ -6,8 +6,12 @@ from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit from api.audit_trail import service as audit_trail_service -from api.cases.enums import AdviceType, CaseTypeEnum, LicenceDecisionType -from api.cases.models import LicenceDecision +from api.cases.enums import ( + AdviceLevel, + AdviceType, + CaseTypeEnum, + LicenceDecisionType, +) from api.cases.tests.factories import FinalAdviceFactory from api.cases.libraries.get_case import get_case from api.cases.generated_documents.models import GeneratedCaseDocument @@ -65,8 +69,19 @@ def test_refuse_standard_application_success(self, send_exporter_notifications_f payload__decision=AdviceType.REFUSE, ).exists() ) - self.assertTrue( - LicenceDecision.objects.filter(case=self.application, decision=LicenceDecisionType.REFUSED).exists() + + licence_decision = case.licence_decisions.get(decision=LicenceDecisionType.REFUSED) + + advice = case.advice.get( + level=AdviceLevel.FINAL, + type=AdviceType.REFUSE, + ) + + self.assertTrue(licence_decision.denial_reasons.exists()) + + self.assertQuerySetEqual( + licence_decision.denial_reasons.all(), + advice.denial_reasons.all(), ) @mock.patch("api.cases.notify.notify_exporter_licence_refused") diff --git a/api/cases/views/views.py b/api/cases/views/views.py index c1f4a1d7bb..f295fbbcb6 100644 --- a/api/cases/views/views.py +++ b/api/cases/views/views.py @@ -894,7 +894,7 @@ def put(self, request, pk): ) # finalises case, grants licence and publishes decision documents - licence_id = case.finalise(request, required_decisions) + licence_id = case.finalise(request.user, required_decisions, request.data.get("note")) return JsonResponse({"case": pk, "licence": licence_id}, status=status.HTTP_201_CREATED) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 0eab2882f6..6806c98f15 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -11,7 +11,10 @@ ) from api.cases.enums import LicenceDecisionType from api.cases.models import LicenceDecision +from api.licences.models import GoodOnLicence +from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country +from api.staticdata.denial_reasons.models import DenialReason from api.staticdata.report_summaries.models import ReportSummary @@ -96,6 +99,15 @@ class Meta: ) +class GoodOnLicenceSerializer(serializers.ModelSerializer): + good_id = serializers.UUIDField() + licence_id = serializers.UUIDField() + + class Meta: + model = GoodOnLicence + fields = ("good_id", "licence_id") + + class ApplicationSerializer(serializers.ModelSerializer): licence_type = serializers.CharField(source="case_type.reference") status = serializers.CharField(source="status.status") @@ -129,3 +141,35 @@ def get_first_closed_at(self, application) -> typing.Optional[datetime.datetime] return application.baseapplication_ptr.case_ptr.closed_status_updates[0].created_at return None + + +class UnitSerializer(serializers.Serializer): + code = serializers.CharField() + description = serializers.CharField() + + +class FootnoteSerializer(serializers.Serializer): + footnote = serializers.CharField() + team_name = serializers.CharField(source="team__name") + application_id = serializers.CharField(source="case__pk") + type = serializers.CharField() + + +class AssessmentSerializer(serializers.ModelSerializer): + good_id = serializers.UUIDField() + + class Meta: + model = ControlListEntry + fields = ( + "good_id", + "rating", + ) + + +class LicenceRefusalCriteriaSerializer(serializers.ModelSerializer): + criteria = serializers.CharField(source="display_value") + licence_decision_id = serializers.UUIDField() + + class Meta: + model = DenialReason + fields = ("criteria", "licence_decision_id") diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index 2c1bace8ad..8c38d25e1f 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -25,19 +25,28 @@ PartyOnApplicationFactory, StandardApplicationFactory, ) -from api.cases.enums import CaseTypeEnum +from api.cases.enums import ( + AdviceType, + CaseTypeEnum, +) from api.cases.models import CaseType +from api.cases.tests.factories import FinalAdviceFactory from api.core.constants import ( ExporterPermissions, GovPermissions, Roles, ) -from api.goods.tests.factories import GoodFactory from api.documents.libraries.s3_operations import init_s3_client +from api.flags.enums import SystemFlags +from api.goods.tests.factories import GoodFactory from api.letter_templates.models import LetterTemplate -from api.parties.tests.factories import PartyDocumentFactory from api.organisations.tests.factories import OrganisationFactory +from api.parties.tests.factories import PartyDocumentFactory from api.staticdata.letter_layouts.models import LetterLayout +from api.staticdata.report_summaries.models import ( + ReportSummaryPrefix, + ReportSummarySubject, +) from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus from api.staticdata.units.enums import Units @@ -130,6 +139,18 @@ def gov_user_permissions(): Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.INTERNAL.value) +@pytest.fixture() +def ogd_advisor(gov_user, gov_user_permissions): + gov_user.role = RoleFactory(name="OGD Advisor", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [ + GovPermissions.MAINTAIN_FOOTNOTES.name, + ] + ) + gov_user.save() + return gov_user + + @pytest.fixture() def lu_case_officer(gov_user, gov_user_permissions): gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) @@ -161,6 +182,11 @@ def gov_headers(gov_user): return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} +@pytest.fixture() +def ogd_advisor_headers(ogd_advisor): + return {"HTTP_GOV_USER_TOKEN": user_to_token(ogd_advisor.baseuser_ptr)} + + @pytest.fixture() def lu_case_officer_headers(lu_case_officer): return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_case_officer.baseuser_ptr)} @@ -363,4 +389,214 @@ def check_rows(client, parse_table, unpage_data, table_name, rows): for row in parsed_rows[1:]: expected_data.append({key: value for key, value in zip(keys, row)}) expected_data = cast_to_types(expected_data, table_metadata["fields"]) + + actual_data = sorted(actual_data, key=lambda d, key=keys[0]: d[key]) + expected_data = sorted(expected_data, key=lambda d, key=keys[0]: d[key]) assert actual_data == expected_data + + +@given(parsers.parse("LITE exports `{table_name}` data to DW")) +def given_endpoint_exists(client, table_name): + metadata_url = reverse("data_workspace:v2:table-metadata") + response = client.get(metadata_url) + assert table_name in [t["table_name"] for t in response.json()["tables"]] + + +@given(parsers.parse("the application has the following goods:{goods}")) +def given_the_application_has_the_following_goods(parse_table, draft_standard_application, goods): + draft_standard_application.goods.all().delete() + good_attributes = parse_table(goods)[1:] + for id, name in good_attributes: + GoodOnApplicationFactory( + application=draft_standard_application, + id=id, + good__name=name, + ) + + +@when(parsers.parse("the goods are assessed by TAU as:{assessments}")) +def when_the_goods_are_assessed_by_tau( + parse_table, + submitted_standard_application, + assessments, + api_client, + lu_case_officer, + gov_headers, +): + assessments = parse_table(assessments)[1:] + url = reverse("assessments:make_assessments", kwargs={"case_pk": submitted_standard_application.pk}) + + assessment_payload = [] + for good_on_application_id, control_list_entry, report_summary_prefix, report_summary_subject in assessments: + data = { + "id": good_on_application_id, + "comment": "Some comment", + } + + if control_list_entry == "NLR": + data.update( + { + "control_list_entries": [], + "is_good_controlled": False, + } + ) + else: + if report_summary_prefix: + prefix = ReportSummaryPrefix.objects.get(name=report_summary_prefix) + else: + prefix = None + subject = ReportSummarySubject.objects.get(name=report_summary_subject) + data.update( + { + "control_list_entries": [control_list_entry], + "report_summary_prefix": prefix.pk if prefix else None, + "report_summary_subject": subject.pk, + "is_good_controlled": True, + "regime_entries": [], + } + ) + assessment_payload.append(data) + + response = api_client.put( + url, + assessment_payload, + **gov_headers, + ) + assert response.status_code == 200, response.content + + +@pytest.fixture() +def parse_attributes(parse_table): + def _parse_attributes(attributes): + kwargs = {} + table_data = parse_table(attributes) + for key, value in table_data[1:]: + kwargs[key] = value + return kwargs + + return _parse_attributes + + +@given( + parsers.parse("a draft standard application with attributes:{attributes}"), + target_fixture="draft_standard_application", +) +def given_a_draft_standard_application_with_attributes(organisation, parse_attributes, attributes): + application = DraftStandardApplicationFactory( + organisation=organisation, + **parse_attributes(attributes), + ) + + PartyDocumentFactory( + party=application.end_user.party, + s3_key="party-document", + safe=True, + ) + + return application + + +@pytest.fixture() +def issue_licence(api_client, lu_case_officer, gov_headers, siel_template): + def _issue_licence(application): + data = {"action": AdviceType.APPROVE, "duration": 24} + for good_on_app in application.goods.all(): + good_on_app.quantity = 100 + good_on_app.value = 10000 + good_on_app.save() + data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) + data[f"value-{good_on_app.id}"] = str(good_on_app.value) + # create final advice for controlled goods; skip NLR goods + if good_on_app.is_good_controlled == False: + continue + FinalAdviceFactory(user=lu_case_officer, case=application, good=good_on_app.good) + + issue_date = datetime.datetime.now() + data.update({"year": issue_date.year, "month": issue_date.month, "day": issue_date.day}) + + application.flags.remove(SystemFlags.ENFORCEMENT_CHECK_REQUIRED) + + url = reverse("applications:finalise", kwargs={"pk": application.pk}) + response = api_client.put(url, data=data, **gov_headers) + assert response.status_code == 200, response.content + response = response.json() + + data = { + "template": str(siel_template.id), + "text": "", + "visible_to_exporter": False, + "advice_type": AdviceType.APPROVE, + } + url = reverse( + "cases:generated_documents:generated_documents", + kwargs={"pk": str(application.pk)}, + ) + response = api_client.post(url, data=data, **gov_headers) + assert response.status_code == 201, response.content + + url = reverse( + "cases:finalise", + kwargs={"pk": str(application.pk)}, + ) + response = api_client.put(url, data={}, **gov_headers) + assert response.status_code == 201 + + return _issue_licence + + +@pytest.fixture() +def refuse_application( + api_client, + lu_case_officer, + siel_refusal_template, + gov_headers, +): + def _refuse_application(application, denial_reasons=None): + if not denial_reasons: + denial_reasons = ["1a", "1b", "1c"] + + data = {"action": AdviceType.REFUSE} + for good_on_app in application.goods.all(): + good_on_app.quantity = 100 + good_on_app.value = 10000 + good_on_app.save() + data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) + data[f"value-{good_on_app.id}"] = str(good_on_app.value) + FinalAdviceFactory( + user=lu_case_officer, + case=application, + good=good_on_app.good, + type=AdviceType.REFUSE, + denial_reasons=denial_reasons, + ) + + application.flags.remove(SystemFlags.ENFORCEMENT_CHECK_REQUIRED) + + url = reverse("applications:finalise", kwargs={"pk": application.pk}) + response = api_client.put(url, data=data, **gov_headers) + assert response.status_code == 200, response.content + response = response.json() + + data = { + "template": str(siel_refusal_template.id), + "text": "", + "visible_to_exporter": False, + "advice_type": AdviceType.REFUSE, + } + url = reverse( + "cases:generated_documents:generated_documents", + kwargs={"pk": str(application.pk)}, + ) + response = api_client.post(url, data=data, **gov_headers) + assert response.status_code == 201, response.content + + url = reverse( + "cases:finalise", + kwargs={"pk": str(application.pk)}, + ) + response = api_client.put(url, data={}, **gov_headers) + assert response.status_code == 201, response.content + + application.refresh_from_db() + + return _refuse_application diff --git a/api/data_workspace/v2/tests/bdd/licences/conftest.py b/api/data_workspace/v2/tests/bdd/licences/conftest.py index 5c666f5e03..9396dd8bae 100644 --- a/api/data_workspace/v2/tests/bdd/licences/conftest.py +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -7,7 +7,10 @@ PartyOnApplicationFactory, DraftStandardApplicationFactory, ) -from api.cases.enums import AdviceType +from api.cases.enums import ( + AdviceLevel, + AdviceType, +) from api.cases.tests.factories import FinalAdviceFactory from api.goods.tests.factories import GoodFactory from api.licences.enums import LicenceStatus @@ -75,7 +78,12 @@ def standard_case_with_final_advice(lu_case_officer): @pytest.fixture() def standard_case_with_refused_advice(lu_case_officer, standard_case_with_final_advice): - standard_case_with_final_advice.advice.update(type=AdviceType.REFUSE) + final_advice = standard_case_with_final_advice.advice.filter(level=AdviceLevel.FINAL) + for advice in final_advice: + advice.type = AdviceType.REFUSE + advice.text = "refusing licence" + advice.denial_reasons.set(["1a", "1b", "1c"]) + advice.save() return standard_case_with_final_advice diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py index e03491faf0..adf8f1c9ad 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py @@ -187,7 +187,7 @@ def case_officer_refuses_licence(client, gov_headers, case_with_refused_advice): kwargs={"pk": str(case_with_refused_advice.pk)}, ) response = client.put(url, {}, content_type="application/json", **gov_headers) - assert response.status_code == 201 + assert response.status_code == 201, response.content case_with_refused_advice.refresh_from_db() assert case_with_refused_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) @@ -297,10 +297,12 @@ def case_ready_to_be_finalised_after_refusing_licence(client, lu_case_officer_he case_with_final_advice = issued_licence.case assert case_with_final_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) - case_with_final_advice.advice.filter(level=AdviceLevel.FINAL).update( - type=AdviceType.REFUSE, - text="refusing licence", - ) + final_advice = case_with_final_advice.advice.filter(level=AdviceLevel.FINAL) + for advice in final_advice: + advice.type = AdviceType.REFUSE + advice.text = "refusing licence" + advice.denial_reasons.set(["1a", "1b", "1c"]) + advice.save() case_with_final_advice = case_reopen_prepare_to_finalise(client, lu_case_officer_headers, case_with_final_advice) diff --git a/api/data_workspace/v2/tests/bdd/scenarios/footnotes.feature b/api/data_workspace/v2/tests/bdd/scenarios/footnotes.feature new file mode 100644 index 0000000000..9662a59456 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/footnotes.feature @@ -0,0 +1,33 @@ +@db +Feature: applications Table + +Scenario: Draft application + Given a draft standard application + Then the `footnotes` table is empty + +Scenario: Submit an application + Given a draft standard application + When the application is submitted + Then the `footnotes` table is empty + +Scenario: Approving an application with footnotes + Given a draft standard application with attributes: + | name | value | + | id | 03fb08eb-1564-4b68-9336-3ca8906543f9 | + When the application is submitted + And a recommendation is added with footnotes: + | team | footnotes | + | FCDO | Commercial end user | + Then the `footnotes` table has the following rows: + | application_id | team_name | type | footnote | + | 03fb08eb-1564-4b68-9336-3ca8906543f9 | FCDO | approve | Commercial end user | + +Scenario: Approving an application with no footnotes + Given a draft standard application with attributes: + | name | value | + | id | 03fb08eb-1564-4b68-9336-3ca8906543f9 | + When the application is submitted + And a recommendation is added with footnotes: + | team | footnotes | + | FCDO | | + Then the `footnotes` table is empty diff --git a/api/data_workspace/v2/tests/bdd/scenarios/goods_descriptions.feature b/api/data_workspace/v2/tests/bdd/scenarios/goods_descriptions.feature index b5e70379a1..5e2197f00d 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/goods_descriptions.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/goods_descriptions.feature @@ -29,5 +29,5 @@ Scenario: Assess application | 4dad5dc6-38ef-4bf7-99fd-0c6bc5d86048 | NLR | | | Then the `goods_descriptions` table has the following rows: | good_id | description | - | 118a003c-7191-4a2c-97e9-be243722cbb2 | composite laminates | | 8fa8dc3c-c103-42f5-ba94-2d9098b8821d | accessories for composite laminates | + | 118a003c-7191-4a2c-97e9-be243722cbb2 | composite laminates | diff --git a/api/data_workspace/v2/tests/bdd/scenarios/goods_on_licences.feature b/api/data_workspace/v2/tests/bdd/scenarios/goods_on_licences.feature new file mode 100644 index 0000000000..3b67b6cded --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/goods_on_licences.feature @@ -0,0 +1,49 @@ +@db +Feature: Goods On Licences + +Scenario: Issue a licence + Given a standard application with the following goods: + | id | name | + | 61d193bd-a4d8-4f7d-8c07-1ac5e03ea2c7 | A controlled good | + And a draft licence with attributes: + | name | value | + | id | 962b4948-b87a-42fe-9c2b-61bdefd9cd21 | + When the licence is issued + Then the `goods_on_licences` table has the following rows: + | good_id | licence_id | + | 61d193bd-a4d8-4f7d-8c07-1ac5e03ea2c7 | 962b4948-b87a-42fe-9c2b-61bdefd9cd21 | + +Scenario: NLR goods not on licence + Given a standard application with the following goods: + | id | name | + | aa9736f9-48f5-4d44-ace9-e4b8738591a5 | Another controlled good | + | 56f562f6-b554-4bb3-923b-8695ab15afca | An NLR good | + And the goods are assessed by TAU as: + | id | Control list entry | Report summary prefix | Report summary subject | + | aa9736f9-48f5-4d44-ace9-e4b8738591a5 | ML5b | accessories for | network analysers | + | 56f562f6-b554-4bb3-923b-8695ab15afca | NLR | | | + And a draft licence with attributes: + | name | value | + | id | 847a9a03-c35f-4036-ab8c-8b58d13482ab | + When the licence is issued + Then the `goods_on_licences` table has the following rows: + | good_id | licence_id | + | aa9736f9-48f5-4d44-ace9-e4b8738591a5 | 847a9a03-c35f-4036-ab8c-8b58d13482ab | + +Scenario: Draft licences + Given a standard application with the following goods: + | id | name | + | f7c674b1-cd5e-4a6d-a1f5-d6ab58149d05 | A controlled good 2 | + And a draft licence with attributes: + | name | value | + | id | 297e89b9-fc93-4f38-be46-c2ab38914007 | + Then the `goods_on_licences` table is empty + +Scenario: Draft applications + Given a draft standard application with the following goods: + | id | name | + | 8262dcf7-d932-4a33-978d-b5aa8a7878ee | A controlled good 3 | + And a draft licence with attributes: + | name | value | + | id | 2078827b-6d67-406c-becc-41c423720cfc | + Then the `goods_on_licences` table is empty diff --git a/api/data_workspace/v2/tests/bdd/scenarios/goods_ratings.feature b/api/data_workspace/v2/tests/bdd/scenarios/goods_ratings.feature new file mode 100644 index 0000000000..df0202b1a5 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/goods_ratings.feature @@ -0,0 +1,33 @@ +@db +Feature: goods_ratings Table + +Scenario: Draft application + Given a draft standard application + Then the `goods_ratings` table is empty + +Scenario: Submitted application + Given a draft standard application + And the application has the following goods: + | id | name | + | 8fa8dc3c-c103-42f5-ba94-2d9098b8821d | A controlled good | + | 4dad5dc6-38ef-4bf7-99fd-0c6bc5d86048 | An NLR good | + When the application is submitted + Then the `goods_ratings` table is empty + +Scenario: Assess application + Given a draft standard application + And the application has the following goods: + | id | name | + | 8fa8dc3c-c103-42f5-ba94-2d9098b8821d | A controlled good | + | 118a003c-7191-4a2c-97e9-be243722cbb2 | Another controlled good | + | 4dad5dc6-38ef-4bf7-99fd-0c6bc5d86048 | An NLR good | + When the application is submitted + And the goods are assessed by TAU as: + | id | Control list entry | Report summary prefix | Report summary subject | + | 8fa8dc3c-c103-42f5-ba94-2d9098b8821d | ML22a | accessories for | composite laminates | + | 118a003c-7191-4a2c-97e9-be243722cbb2 | PL9010 | | composite laminates | + | 4dad5dc6-38ef-4bf7-99fd-0c6bc5d86048 | NLR | | | + Then the `goods_ratings` table has the following rows: + | good_id | rating | + | 8fa8dc3c-c103-42f5-ba94-2d9098b8821d | ML22a | + | 118a003c-7191-4a2c-97e9-be243722cbb2 | PL9010 | diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_refusal_criteria.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_refusal_criteria.feature new file mode 100644 index 0000000000..a57c6aeab5 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_refusal_criteria.feature @@ -0,0 +1,28 @@ +@db +Feature: licence_refusal_criteria Table + +Scenario: Draft application + Given a draft standard application + Then the `licence_refusal_criteria` table is empty + +Scenario: Submit an application + Given a draft standard application + When the application is submitted + Then the `licence_refusal_criteria` table is empty + +Scenario: Issuing an application + Given a draft standard application + When the application is submitted + And the application is issued + Then the `licence_refusal_criteria` table is empty + +Scenario: Refusing an application + Given a draft standard application + When the application is submitted + And the application is refused with criteria: + | 1 | + | 2c | + Then the `licence_refusal_criteria` table has the following rows: + | licence_decision_id | criteria | + | 03fb08eb-1564-4b68-9336-3ca8906543f9 | 1 | + | 03fb08eb-1564-4b68-9336-3ca8906543f9 | 2c | diff --git a/api/data_workspace/v2/tests/bdd/scenarios/units.feature b/api/data_workspace/v2/tests/bdd/scenarios/units.feature new file mode 100644 index 0000000000..7a54ce948a --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/units.feature @@ -0,0 +1,19 @@ +@db +Feature: units Table + +Scenario: Units endpoint + Given LITE exports `units` data to DW + Then the `units` table has the following rows: + | code | description | + | NAR | Items | + | TON | Tonnes | + | KGM | Kilograms | + | GRM | Grams | + | MGM | Milligrams | + | MCG | Micrograms | + | MTR | Metres | + | MTK | Square metres | + | MTQ | Cubic metres | + | LTR | Litres | + | MLT | Millilitres | + | MCL | Microlitres | diff --git a/api/data_workspace/v2/tests/bdd/test_applications.py b/api/data_workspace/v2/tests/bdd/test_applications.py index 1a2c1ac20c..f96d994753 100644 --- a/api/data_workspace/v2/tests/bdd/test_applications.py +++ b/api/data_workspace/v2/tests/bdd/test_applications.py @@ -4,8 +4,6 @@ from freezegun import freeze_time -from moto import mock_aws - from pytest_bdd import ( given, parsers, @@ -13,7 +11,6 @@ when, ) -from django.conf import settings from django.urls import reverse from api.applications.enums import ApplicationExportType @@ -27,9 +24,6 @@ AdviceLevel, AdviceType, ) -from api.cases.tests.factories import FinalAdviceFactory -from api.documents.libraries.s3_operations import init_s3_client -from api.flags.enums import SystemFlags from api.licences.enums import LicenceStatus from api.parties.tests.factories import ( PartyDocumentFactory, @@ -41,18 +35,6 @@ scenarios("./scenarios/applications.feature") -@pytest.fixture() -def parse_attributes(parse_table): - def _parse_attributes(attributes): - kwargs = {} - table_data = parse_table(attributes) - for key, value in table_data[1:]: - kwargs[key] = value - return kwargs - - return _parse_attributes - - def run_processing_time_task(start, up_to): processing_time_task_run_date_time = start.replace(hour=22, minute=30) up_to = pytz.utc.localize(datetime.datetime.fromisoformat(up_to)) @@ -62,19 +44,6 @@ def run_processing_time_task(start, up_to): processing_time_task_run_date_time = processing_time_task_run_date_time + datetime.timedelta(days=1) -@pytest.fixture(autouse=True) -def mock_s3(): - with mock_aws(): - s3 = init_s3_client() - s3.create_bucket( - Bucket=settings.AWS_STORAGE_BUCKET_NAME, - CreateBucketConfiguration={ - "LocationConstraint": settings.AWS_REGION, - }, - ) - yield - - @pytest.fixture def submit_application(api_client, exporter_headers, mocker): def _submit_application(draft_application): @@ -103,51 +72,6 @@ def _submit_application(draft_application): return _submit_application -@pytest.fixture() -def issue_licence(api_client, lu_case_officer, gov_headers, siel_template): - def _issue_licence(application): - data = {"action": AdviceType.APPROVE, "duration": 24} - for good_on_app in application.goods.all(): - good_on_app.quantity = 100 - good_on_app.value = 10000 - good_on_app.save() - data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) - data[f"value-{good_on_app.id}"] = str(good_on_app.value) - FinalAdviceFactory(user=lu_case_officer, case=application, good=good_on_app.good) - - issue_date = datetime.datetime.now() - data.update({"year": issue_date.year, "month": issue_date.month, "day": issue_date.day}) - - application.flags.remove(SystemFlags.ENFORCEMENT_CHECK_REQUIRED) - - url = reverse("applications:finalise", kwargs={"pk": application.pk}) - response = api_client.put(url, data=data, **gov_headers) - assert response.status_code == 200, response.content - response = response.json() - - data = { - "template": str(siel_template.id), - "text": "", - "visible_to_exporter": False, - "advice_type": AdviceType.APPROVE, - } - url = reverse( - "cases:generated_documents:generated_documents", - kwargs={"pk": str(application.pk)}, - ) - response = api_client.post(url, data=data, **gov_headers) - assert response.status_code == 201, response.content - - url = reverse( - "cases:finalise", - kwargs={"pk": str(application.pk)}, - ) - response = api_client.put(url, data={}, **gov_headers) - assert response.status_code == 201 - - return _issue_licence - - @pytest.fixture() def caseworker_change_status(api_client, lu_case_officer, lu_case_officer_headers): def _caseworker_change_status(application, status): @@ -189,40 +113,6 @@ def _exporter_change_status(application, status): return _exporter_change_status -@given("a draft standard application", target_fixture="draft_standard_application") -def given_draft_standard_application(organisation): - application = DraftStandardApplicationFactory( - organisation=organisation, - ) - - PartyDocumentFactory( - party=application.end_user.party, - s3_key="party-document", - safe=True, - ) - - return application - - -@given( - parsers.parse("a draft standard application with attributes:{attributes}"), - target_fixture="draft_standard_application", -) -def given_a_draft_standard_application_with_attributes(organisation, parse_attributes, attributes): - application = DraftStandardApplicationFactory( - organisation=organisation, - **parse_attributes(attributes), - ) - - PartyDocumentFactory( - party=application.end_user.party, - s3_key="party-document", - safe=True, - ) - - return application - - @given( parsers.parse("a draft temporary standard application with attributes:{attributes}"), target_fixture="draft_standard_application", @@ -270,14 +160,6 @@ def given_a_good_is_onward_incorporated(draft_standard_application): ) -@when( - "the application is submitted", - target_fixture="submitted_standard_application", -) -def when_the_application_is_submitted(submit_application, draft_standard_application): - return submit_application(draft_standard_application) - - @when( parsers.parse("the application is submitted at {submission_time}"), target_fixture="submitted_standard_application", @@ -306,61 +188,19 @@ def when_the_application_is_issued_at( @when(parsers.parse("the application is refused at {timestamp}"), target_fixture="refused_application") def when_the_application_is_refused_at( - api_client, - lu_case_officer, - siel_refusal_template, - gov_headers, submitted_standard_application, + refuse_application, timestamp, ): run_processing_time_task(submitted_standard_application.submitted_at, timestamp) with freeze_time(timestamp): - data = {"action": AdviceType.REFUSE} - for good_on_app in submitted_standard_application.goods.all(): - good_on_app.quantity = 100 - good_on_app.value = 10000 - good_on_app.save() - data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) - data[f"value-{good_on_app.id}"] = str(good_on_app.value) - FinalAdviceFactory( - user=lu_case_officer, - case=submitted_standard_application, - good=good_on_app.good, - type=AdviceType.REFUSE, - ) - - submitted_standard_application.flags.remove(SystemFlags.ENFORCEMENT_CHECK_REQUIRED) - - url = reverse("applications:finalise", kwargs={"pk": submitted_standard_application.pk}) - response = api_client.put(url, data=data, **gov_headers) - assert response.status_code == 200, response.content - response = response.json() - - data = { - "template": str(siel_refusal_template.id), - "text": "", - "visible_to_exporter": False, - "advice_type": AdviceType.REFUSE, - } - url = reverse( - "cases:generated_documents:generated_documents", - kwargs={"pk": str(submitted_standard_application.pk)}, - ) - response = api_client.post(url, data=data, **gov_headers) - assert response.status_code == 201, response.content - - url = reverse( - "cases:finalise", - kwargs={"pk": str(submitted_standard_application.pk)}, - ) - response = api_client.put(url, data={}, **gov_headers) - assert response.status_code == 201, response.content + refuse_application(submitted_standard_application) - submitted_standard_application.refresh_from_db() - refused_application = submitted_standard_application + submitted_standard_application.refresh_from_db() + refused_application = submitted_standard_application - return refused_application + return refused_application @when(parsers.parse("the application is appealed at {timestamp}"), target_fixture="appealed_application") diff --git a/api/data_workspace/v2/tests/bdd/test_footnotes.py b/api/data_workspace/v2/tests/bdd/test_footnotes.py new file mode 100644 index 0000000000..b3b3ceb470 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_footnotes.py @@ -0,0 +1,61 @@ +import pytest + +from pytest_bdd import ( + parsers, + scenarios, + when, +) + +from django.urls import reverse + +from api.staticdata.statuses.enums import CaseStatusEnum +from api.teams.models import Team + + +scenarios("./scenarios/footnotes.feature") + + +@pytest.fixture() +def add_recommendation( + api_client, + ogd_advisor, + ogd_advisor_headers, +): + def _add_recommendation(application, footnotes_data): + + url = reverse("caseworker_applications:change_status", kwargs={"pk": str(application.pk)}) + response = api_client.post(url, data={"status": CaseStatusEnum.OGD_ADVICE}, **ogd_advisor_headers) + assert response.status_code == 200 + + ogd_advisor.team = Team.objects.get(name=footnotes_data[0]["team"]) + ogd_advisor.save() + + subjects = [("good_id", good_on_application.good.id) for good_on_application in application.goods.all()] + [ + (poa.party.type, poa.party.id) for poa in application.parties.all() + ] + + data = [ + { + "type": "approve", + "text": "Recommend issuing licence", + "proviso": "", + "footnote_required": True, + "footnote": footnotes_data[0]["footnotes"], + subject_name: str(subject_id), + "denial_reasons": [], + } + for subject_name, subject_id in subjects + ] + url = reverse("cases:user_advice", kwargs={"pk": str(application.pk)}) + response = api_client.post(url, data=data, **ogd_advisor_headers) + assert response.status_code == 201 + + return _add_recommendation + + +@when(parsers.parse("a recommendation is added with footnotes:{footnotes}")) +def when_a_recommendation_is_added_with_footnotes( + submitted_standard_application, add_recommendation, parse_table, footnotes +): + footnotes_data = [{"team": team, "footnotes": text} for team, text in parse_table(footnotes)[1:]] + add_recommendation(submitted_standard_application, footnotes_data) diff --git a/api/data_workspace/v2/tests/bdd/test_goods_descriptions.py b/api/data_workspace/v2/tests/bdd/test_goods_descriptions.py index 88083a1278..8128fdd7f4 100644 --- a/api/data_workspace/v2/tests/bdd/test_goods_descriptions.py +++ b/api/data_workspace/v2/tests/bdd/test_goods_descriptions.py @@ -1,80 +1,4 @@ -from pytest_bdd import ( - given, - parsers, - scenarios, - when, -) - -from django.urls import reverse - -from api.applications.tests.factories import GoodOnApplicationFactory -from api.staticdata.report_summaries.models import ( - ReportSummaryPrefix, - ReportSummarySubject, -) +from pytest_bdd import scenarios scenarios("./scenarios/goods_descriptions.feature") - - -@given(parsers.parse("the application has the following goods:{goods}")) -def given_the_application_has_the_following_goods(parse_table, draft_standard_application, goods): - draft_standard_application.goods.all().delete() - good_attributes = parse_table(goods)[1:] - for id, name in good_attributes: - GoodOnApplicationFactory( - application=draft_standard_application, - id=id, - good__name=name, - ) - - -@when(parsers.parse("the goods are assessed by TAU as:{assessments}")) -def when_the_goods_are_assessed_by_tau( - parse_table, - submitted_standard_application, - assessments, - api_client, - lu_case_officer, - gov_headers, -): - assessments = parse_table(assessments)[1:] - url = reverse("assessments:make_assessments", kwargs={"case_pk": submitted_standard_application.pk}) - - assessment_payload = [] - for good_on_application_id, control_list_entry, report_summary_prefix, report_summary_subject in assessments: - data = { - "id": good_on_application_id, - "comment": "Some comment", - } - - if control_list_entry == "NLR": - data.update( - { - "control_list_entries": [], - "is_good_controlled": False, - } - ) - else: - if report_summary_prefix: - prefix = ReportSummaryPrefix.objects.get(name=report_summary_prefix) - else: - prefix = None - subject = ReportSummarySubject.objects.get(name=report_summary_subject) - data.update( - { - "control_list_entries": [control_list_entry], - "report_summary_prefix": prefix.pk if prefix else None, - "report_summary_subject": subject.pk, - "is_good_controlled": True, - "regime_entries": [], - } - ) - assessment_payload.append(data) - - response = api_client.put( - url, - assessment_payload, - **gov_headers, - ) - assert response.status_code == 200, response.content diff --git a/api/data_workspace/v2/tests/bdd/test_goods_on_licences.py b/api/data_workspace/v2/tests/bdd/test_goods_on_licences.py new file mode 100644 index 0000000000..9c51e66aa4 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_goods_on_licences.py @@ -0,0 +1,106 @@ +from django.urls import reverse +from pytest_bdd import given, parsers, scenarios, when + +from api.applications.tests.factories import GoodOnApplicationFactory +from api.licences.tests.factories import StandardLicenceFactory +from api.licences.enums import LicenceStatus +from api.staticdata.report_summaries.models import ( + ReportSummaryPrefix, + ReportSummarySubject, +) + +scenarios("./scenarios/goods_on_licences.feature") + + +@given(parsers.parse("a standard application with the following goods:{goods}"), target_fixture="standard_application") +def standard_application_with_following_goods(parse_table, goods, standard_application): + standard_application.goods.all().delete() + good_attributes = parse_table(goods)[1:] + for id, name in good_attributes: + GoodOnApplicationFactory( + application=standard_application, + id=id, + good__name=name, + ) + return standard_application + + +@given(parsers.parse("a draft licence with attributes:{attributes}"), target_fixture="draft_licence") +def draft_licence_with_attributes(parse_attributes, attributes, standard_application): + draft_licence = StandardLicenceFactory( + case=standard_application, status=LicenceStatus.DRAFT, **parse_attributes(attributes) + ) + return draft_licence + + +@when("the licence is issued") +def licence_is_issued(standard_application, issue_licence): + issue_licence(standard_application) + standard_application.refresh_from_db() + return standard_application + + +@given(parsers.parse("the goods are assessed by TAU as:{assessments}")) +def the_goods_are_assessed_by_tau_as( + parse_table, + standard_application, + assessments, + api_client, + lu_case_officer, + gov_headers, +): + assessments = parse_table(assessments)[1:] + url = reverse("assessments:make_assessments", kwargs={"case_pk": standard_application.pk}) + + assessment_payload = [] + for good_on_application_id, control_list_entry, report_summary_prefix, report_summary_subject in assessments: + data = { + "id": good_on_application_id, + "comment": "Some comment", + } + + if control_list_entry == "NLR": + data.update( + { + "control_list_entries": [], + "is_good_controlled": False, + } + ) + else: + if report_summary_prefix: + prefix = ReportSummaryPrefix.objects.get(name=report_summary_prefix) + else: + prefix = None + subject = ReportSummarySubject.objects.get(name=report_summary_subject) + data.update( + { + "control_list_entries": [control_list_entry], + "report_summary_prefix": prefix.pk if prefix else None, + "report_summary_subject": subject.pk, + "is_good_controlled": True, + "regime_entries": [], + } + ) + assessment_payload.append(data) + + response = api_client.put( + url, + assessment_payload, + **gov_headers, + ) + assert response.status_code == 200, response.content + + +@given( + parsers.parse("a draft standard application with the following goods:{goods}"), target_fixture="draft_application" +) +def draft_standard_application_with_following_goods(parse_table, goods, draft_application): + draft_application.goods.all().delete() + good_attributes = parse_table(goods)[1:] + for id, name in good_attributes: + GoodOnApplicationFactory( + application=draft_application, + id=id, + good__name=name, + ) + return draft_application diff --git a/api/data_workspace/v2/tests/bdd/test_goods_ratings.py b/api/data_workspace/v2/tests/bdd/test_goods_ratings.py new file mode 100644 index 0000000000..e1f3e17865 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_goods_ratings.py @@ -0,0 +1,4 @@ +from pytest_bdd import scenarios + + +scenarios("./scenarios/goods_ratings.feature") diff --git a/api/data_workspace/v2/tests/bdd/test_licence_refusal_criteria.py b/api/data_workspace/v2/tests/bdd/test_licence_refusal_criteria.py new file mode 100644 index 0000000000..6637981aa1 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_licence_refusal_criteria.py @@ -0,0 +1,39 @@ +import uuid + +from pytest_bdd import ( + parsers, + scenarios, + when, +) + + +scenarios("./scenarios/licence_refusal_criteria.feature") + + +@when(parsers.parse("the application is refused with criteria:{criteria}")) +def when_the_application_is_refused_with_criteria( + submitted_standard_application, refuse_application, parse_table, criteria +): + criteria = [c[0] for c in parse_table(criteria)] + refuse_application(submitted_standard_application, criteria) + refused_application = submitted_standard_application + licence_decision = refused_application.licence_decisions.get() + old_pk = licence_decision.pk + licence_decision.pk = uuid.UUID("03fb08eb-1564-4b68-9336-3ca8906543f9") # /PS-IGNORE + licence_decision.save() + licence_decision.denial_reasons.through.objects.filter(licencedecision_id=old_pk).update( + licencedecision_id=licence_decision.pk + ) + + +@when("the application is issued") +def when_the_application_is_issued( + issue_licence, + submitted_standard_application, +): + issue_licence(submitted_standard_application) + + submitted_standard_application.refresh_from_db() + issued_application = submitted_standard_application + + return issued_application diff --git a/api/data_workspace/v2/tests/bdd/test_units.py b/api/data_workspace/v2/tests/bdd/test_units.py new file mode 100644 index 0000000000..2cb318f5ae --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_units.py @@ -0,0 +1,4 @@ +from pytest_bdd import scenarios + + +scenarios("./scenarios/units.feature") diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 68e22fcaa1..08121a86ad 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -8,4 +8,9 @@ router_v2.register(views.DestinationViewSet) router_v2.register(views.GoodViewSet) router_v2.register(views.GoodDescriptionViewSet) +router_v2.register(views.GoodOnLicenceViewSet) router_v2.register(views.ApplicationViewSet) +router_v2.register(views.UnitViewSet) +router_v2.register(views.FootnoteViewSet) +router_v2.register(views.AssessmentViewSet) +router_v2.register(views.LicenceRefusalCriteriaViewSet) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index ed96d178af..d1ffe94ea7 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -24,20 +24,30 @@ ) from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit -from api.cases.models import LicenceDecision +from api.cases.models import Advice, LicenceDecision from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( ApplicationSerializer, + AssessmentSerializer, CountrySerializer, DestinationSerializer, + FootnoteSerializer, GoodDescriptionSerializer, GoodSerializer, LicenceDecisionSerializer, + UnitSerializer, + GoodOnLicenceSerializer, + LicenceRefusalCriteriaSerializer, ) +from api.licences.enums import LicenceStatus +from api.licences.models import GoodOnLicence +from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country from api.staticdata.report_summaries.models import ReportSummary +from api.staticdata.denial_reasons.models import DenialReason from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.units.enums import Units class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -117,6 +127,17 @@ def get_closed_statuses(): ) +class GoodOnLicenceViewSet(BaseViewSet): + serializer_class = GoodOnLicenceSerializer + queryset = GoodOnLicence.objects.exclude( + licence__case__status__status=CaseStatusEnum.DRAFT, + licence__status=LicenceStatus.DRAFT, + ) + + class DataWorkspace: + table_name = "goods_on_licences" + + class ApplicationViewSet(BaseViewSet): serializer_class = ApplicationSerializer queryset = ( @@ -141,3 +162,50 @@ class ApplicationViewSet(BaseViewSet): class DataWorkspace: table_name = "applications" + + +class UnitViewSet(BaseViewSet): + serializer_class = UnitSerializer + queryset = [{"code": code, "description": description} for code, description in Units.choices] + + class DataWorkspace: + table_name = "units" + + +class FootnoteViewSet(BaseViewSet): + serializer_class = FootnoteSerializer + queryset = ( + Advice.objects.exclude(Q(footnote="") | Q(footnote__isnull=True)) + .values("footnote", "team__name", "case__pk", "type") + .order_by("case__pk") + .distinct() + ) + + class DataWorkspace: + table_name = "footnotes" + + +class AssessmentViewSet(BaseViewSet): + serializer_class = AssessmentSerializer + + def get_queryset(self): + return ( + ControlListEntry.objects.annotate( + good_id=F("goodonapplication__id"), + ) + .exclude(good_id__isnull=True) + .order_by("rating") + ) + + class DataWorkspace: + table_name = "goods_ratings" + + +class LicenceRefusalCriteriaViewSet(BaseViewSet): + serializer_class = LicenceRefusalCriteriaSerializer + queryset = DenialReason.objects.exclude(licencedecision__denial_reasons__isnull=True).annotate( + licence_decision_id=F("licencedecision__id") + ) + + class DataWorkspace: + table_name = "licence_refusal_criteria" diff --git a/pii-secret-exclude.txt b/pii-secret-exclude.txt index ecf8253680..98c9152fc0 100644 --- a/pii-secret-exclude.txt +++ b/pii-secret-exclude.txt @@ -117,3 +117,5 @@ api/staticdata/report_summaries/migrations/data/0002_add_report_summaries/report api/cases/generated_documents/tests/data/dummy.pdf api/cases/generated_documents/tests/data/signed.pdf api/data_workspace/v2/tests/bdd/scenarios/applications.feature +api/data_workspace/v2/tests/bdd/scenarios/goods_on_licences.feature +api/data_workspace/v2/tests/bdd/scenarios/licence_refusal_criteria.feature