diff --git a/api/cases/enums.py b/api/cases/enums.py index 40915d77b..361ad0a1b 100644 --- a/api/cases/enums.py +++ b/api/cases/enums.py @@ -417,11 +417,13 @@ class LicenceDecisionType: ISSUED = "issued" REFUSED = "refused" REVOKED = "revoked" + ISSUED_ON_APPEAL = "issued_on_appeal" choices = [ (ISSUED, "issued"), (REFUSED, "refused"), (REVOKED, "revoked"), + (ISSUED_ON_APPEAL, "issued_on_appeal"), ] decision_map = { @@ -439,8 +441,13 @@ def templates(cls): cls.ISSUED: SIEL_LICENCE_TEMPLATE_ID, cls.REFUSED: SIEL_REFUSAL_TEMPLATE_ID, cls.REVOKED: None, + cls.ISSUED_ON_APPEAL: None, } @classmethod def advice_type_to_decision(cls, advice_type): return cls.decision_map[advice_type] + + @classmethod + def get_template(cls, decision): + return cls.templates()[decision] diff --git a/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py b/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py new file mode 100644 index 000000000..c23ec6d54 --- /dev/null +++ b/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-14 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0068_populate_licence_decisions"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="excluded_from_statistics_reason", + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/api/cases/migrations/0070_attach_licence_to_licence_decision.py b/api/cases/migrations/0070_attach_licence_to_licence_decision.py new file mode 100644 index 000000000..0e6619775 --- /dev/null +++ b/api/cases/migrations/0070_attach_licence_to_licence_decision.py @@ -0,0 +1,96 @@ +# Generated by Django 4.2.16 on 2024-11-13 17:19 +import functools +import operator + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db import migrations, transaction +from django.db.models import Case as DBCase, Q, TextField, Value, When +from django.db.models.functions import Cast + +from api.audit_trail.enums import AuditType +from api.cases.enums import AdviceType, LicenceDecisionType + + +@transaction.atomic +def attach_licence_to_licence_decisions(apps, schema_editor): + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + Audit = apps.get_model("audit_trail", "Audit") + GeneratedCaseDocument = apps.get_model("generated_documents", "GeneratedCaseDocument") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + Licence = apps.get_model("licences", "Licence") + + licence_decisions_to_update = [] + + final_decision_qs = Audit.objects.filter(verb=AuditType.CREATED_FINAL_RECOMMENDATION).order_by("-created_at") + + document_qs = ( + GeneratedCaseDocument.objects.filter( + template_id__in=LicenceDecisionType.templates().values(), + advice_type=AdviceType.APPROVE, + visible_to_exporter=True, + safe=True, + ) + .annotate(template_ids=ArrayAgg(Cast("template_id", output_field=TextField()), distinct=True)) + .filter( + functools.reduce( + operator.or_, + [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + ) + ) + .annotate( + decision=DBCase( + *[ + When(template_ids=[template_id], then=Value(decision)) + for decision, template_id in LicenceDecisionType.templates().items() + ] + ) + ) + ) + + # When running tests audit entries are not available so filtering documents + # the audit log created date earlier fails + if final_decision_qs: + earliest_audit_log = final_decision_qs.last() + document_qs = document_qs.filter( + created_at__date__lt=earliest_audit_log.created_at.date(), + ) + + for audit_log in final_decision_qs: + advice_type = audit_log.payload["decision"] + if advice_type != AdviceType.APPROVE: + continue + + decision = LicenceDecisionType.advice_type_to_decision(advice_type) + obj = LicenceDecision.objects.get( + case_id=str(audit_log.target_object_id), + decision=decision, + created_at=audit_log.created_at, + ) + obj.licence = Licence.objects.get(reference_code=audit_log.payload["licence_reference"]) + licence_decisions_to_update.append(obj) + + for document in document_qs: + obj = LicenceDecision.objects.get( + case_id=str(document.case_id), + decision=document.decision, + created_at=document.created_at, + ) + obj.licence = document.licence + licence_decisions_to_update.append(obj) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["licence"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0069_licencedecision_excluded_from_statistics_reason"), + ] + + operations = [ + migrations.RunPython( + attach_licence_to_licence_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/0071_licencedecision_previous_decision.py b/api/cases/migrations/0071_licencedecision_previous_decision.py new file mode 100644 index 000000000..d028cd138 --- /dev/null +++ b/api/cases/migrations/0071_licencedecision_previous_decision.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.16 on 2024-11-18 13:14 + +from django.db import migrations, models, transaction +from django.db.models import Count +import django.db.models.deletion + + +@transaction.atomic +def populate_previous_decisions(apps, schema_editor): + Case = apps.get_model("cases", "Case") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + # We only need to update cases where there are multiple decisions + # In case of single decision then previous_decision field of licence decision is + # not set by default so nothing to update + case_qs = ( + Case.objects.all() + .annotate( + num_decisions=Count("licence_decisions"), + ) + .filter( + num_decisions__gt=1, + ) + ) + + # By default previous_decision is not set for all decisions. + # Previous_decision is reset whenever there is a change in decision otherwise it + # points to the decision in previous instance. + # + # When exporting we just filter all decisions where previous_decision is None which + # gives us the earliest decision time as required + # --------------------------------------------------- + # [ld1, ld2, ..., ldn] | [previous_decision field value] + # --------------------------------------------------- + # [ISSUED, ISSUED] | [None, ld1] + # [ISSUED, ISSUED, ISSUED] | [None, ld1, ld2] + # [ISSUED, REFUSED] | [None, None] + # [REFUSED, ISSUED] | [None, None] + # [ISSUED, REVOKED] | [None, None] + # [ISSUED, REVOKED, ISSUED] | [None, None, None] + # --------------------------------------------------- + # + for case in case_qs: + previous_decision = None + for item in case.licence_decisions.order_by("created_at"): + if previous_decision and item.decision == previous_decision.decision: + item.previous_decision = previous_decision + licence_decisions_to_update.append(item) + + previous_decision = item + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["previous_decision"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0070_attach_licence_to_licence_decision"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="previous_decision", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="previous_decisions", + to="cases.licencedecision", + ), + ), + migrations.RunPython( + populate_previous_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/0072_licencedecision_denial_reasons.py b/api/cases/migrations/0072_licencedecision_denial_reasons.py new file mode 100644 index 000000000..377c0eb57 --- /dev/null +++ b/api/cases/migrations/0072_licencedecision_denial_reasons.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-21 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("denial_reasons", "0006_populate_uuid_field"), + ("cases", "0071_licencedecision_previous_decision"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="denial_reasons", + field=models.ManyToManyField(to="denial_reasons.denialreason"), + ), + ] diff --git a/api/cases/migrations/0073_update_licencedecision_denial_reasons.py b/api/cases/migrations/0073_update_licencedecision_denial_reasons.py new file mode 100644 index 000000000..817472905 --- /dev/null +++ b/api/cases/migrations/0073_update_licencedecision_denial_reasons.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.16 on 2024-11-21 13:24 + +from django.db import migrations + +from api.audit_trail.enums import AuditType + + +def update_licencedecision_denial_reasons(apps, schema_editor): + LicenceDecision = apps.get_model("cases", "LicenceDecision") + Advice = apps.get_model("cases", "Advice") + + denial_reasons = ( + Advice.objects.filter( + case__licence_decisions__decision="refused", + team_id="58e77e47-42c8-499f-a58d-94f94541f8c6", # Just care about LU advice + ) + .only("denial_reasons__display_value", "case__licence_decisions__id") + .exclude(denial_reasons__display_value__isnull=True) # This removes refusals without any criteria + .values_list("denial_reasons__display_value", "case__licence_decisions__id") + .order_by() # We need to remove the order_by to make sure the distinct works + .distinct() + ) + + updated_cases = set() + for denial_reason, licence_decision_id in denial_reasons: + licence_decision = LicenceDecision.objects.get(pk=licence_decision_id) + licence_decision.denial_reasons.add(denial_reason) + updated_cases.add(licence_decision.case.pk) + + Audit = apps.get_model("audit_trail", "Audit") + Case = apps.get_model("cases", "Case") + refusal_criteria_audits = Audit.objects.exclude(target_object_id__in=updated_cases).filter( + verb=AuditType.CREATE_REFUSAL_CRITERIA + ) + for audit in refusal_criteria_audits: + case = Case.objects.get(pk=audit.target_object_id) + refusal_licence_decisions = case.licence_decisions.filter(decision="refused") + if not refusal_licence_decisions.exists(): + continue + for licence_decision in refusal_licence_decisions: + denial_reasons = audit.payload["additional_text"].replace(".", "").replace(" ", "").split(",") + licence_decision.denial_reasons.set(denial_reasons) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0072_licencedecision_denial_reasons"), + ] + + operations = [ + migrations.RunPython( + update_licencedecision_denial_reasons, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/0074_alter_licencedecision_options_and_more.py b/api/cases/migrations/0074_alter_licencedecision_options_and_more.py new file mode 100644 index 000000000..a4473967f --- /dev/null +++ b/api/cases/migrations/0074_alter_licencedecision_options_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.16 on 2024-11-21 23:21 + +from django.db import migrations, models, transaction + +from django.db.models import Count + +from api.cases.enums import LicenceDecisionType + + +@transaction.atomic +def populate_issued_on_appeal(apps, schema_editor): + Case = apps.get_model("cases", "Case") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + # Filter cases that have two decisions because for appeals + # the first decision will be refused which is issued on appeal later + case_qs = ( + Case.objects.all() + .annotate( + num_decisions=Count("licence_decisions"), + ) + .filter( + num_decisions=2, + ) + ) + + for case in case_qs: + prev, current = case.licence_decisions.all() + if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: + current.decision = LicenceDecisionType.ISSUED_ON_APPEAL + licence_decisions_to_update.append(current) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["decision"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0073_update_licencedecision_denial_reasons"), + ] + + operations = [ + migrations.AlterModelOptions( + name="licencedecision", + options={"ordering": ("created_at",)}, + ), + migrations.AlterField( + model_name="licencedecision", + name="decision", + field=models.CharField( + choices=[ + ("issued", "issued"), + ("refused", "refused"), + ("revoked", "revoked"), + ("issued_on_appeal", "issued_on_appeal"), + ], + max_length=50, + ), + ), + migrations.RunPython( + populate_issued_on_appeal, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py b/api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py new file mode 100644 index 000000000..82b4b0077 --- /dev/null +++ b/api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py @@ -0,0 +1,17 @@ +import pytest + +from api.cases.enums import LicenceDecisionType + +INITIAL_MIGRATION = "0069_licencedecision_excluded_from_statistics_reason" +MIGRATION_UNDER_TEST = "0070_attach_licence_to_licence_decision" + + +@pytest.mark.django_db() +def test_attach_licence_to_licence_decisions(migrator): + + old_state = migrator.apply_initial_migration(("cases", INITIAL_MIGRATION)) + new_state = migrator.apply_tested_migration(("cases", MIGRATION_UNDER_TEST)) + + LicenceDecision = new_state.apps.get_model("cases", "LicenceDecision") + + assert LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED, licence__isnull=True).count() == 0 diff --git a/api/cases/models.py b/api/cases/models.py index c927d9214..8f303b9da 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -382,16 +382,45 @@ def finalise(self, request, decisions): decision_actions = self.get_decision_actions() for advice_type in decisions: + decision_actions[advice_type](self) # NLR is not considered as licence decision if advice_type in [AdviceType.APPROVE, AdviceType.REFUSE]: - LicenceDecision.objects.create( + decision = LicenceDecisionType.advice_type_to_decision(advice_type) + previous_licence_decision = self.licence_decisions.last() + + previous_decision = None + current_decision = decision + if previous_licence_decision and decision == LicenceDecisionType.ISSUED: + # In case if it is being issued after an appeal then we want to reflect that in the decision + if previous_licence_decision.decision in [ + LicenceDecisionType.REFUSED, + LicenceDecisionType.ISSUED_ON_APPEAL, + ]: + current_decision = LicenceDecisionType.ISSUED_ON_APPEAL + + # link up to previous instance if the decision remains same + if previous_licence_decision.decision == current_decision: + previous_decision = previous_licence_decision + + licence_decision = LicenceDecision.objects.create( case=self, - decision=LicenceDecisionType.advice_type_to_decision(advice_type), + decision=current_decision, licence=licence, + previous_decision=previous_decision, ) + if advice_type == AdviceType.REFUSE: + denial_reasons = ( + self.advice.filter(team_id="58e77e47-42c8-499f-a58d-94f94541f8c6") + .only("denial_reasons__id") + .order_by() + .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, @@ -810,6 +839,14 @@ class LicenceDecision(TimestampableModel): licence = models.ForeignKey( "licences.Licence", on_delete=models.DO_NOTHING, related_name="licence_decisions", null=True, blank=True ) + 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 + ) + denial_reasons = models.ManyToManyField(DenialReason) + + class Meta: + ordering = ("created_at",) def __str__(self): return f"{self.case.reference_code} - {self.decision} ({self.created_at})" diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index d5f48f94c..0454a0506 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,33 +1,186 @@ from rest_framework import serializers +from api.applications.models import ( + GoodOnApplication, + PartyOnApplication, + StandardApplication, +) from api.cases.enums import LicenceDecisionType -from api.cases.models import Case +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 +from api.staticdata.statuses.enums import CaseStatusEnum class LicenceDecisionSerializer(serializers.ModelSerializer): - decision = serializers.SerializerMethodField() - decision_made_at = serializers.SerializerMethodField() + application_id = serializers.CharField(source="case.id") + decision_made_at = serializers.CharField(source="created_at") + licence_id = serializers.SerializerMethodField() class Meta: - model = Case + model = LicenceDecision fields = ( "id", - "reference_code", + "application_id", "decision", "decision_made_at", + "licence_id", ) - def get_decision(self, case): - return case.decision + def get_licence_id(self, licence_decision): + if licence_decision.decision in [LicenceDecisionType.REFUSED, LicenceDecisionType.REVOKED]: + return "" + + latest_decision = licence_decision.case.licence_decisions.exclude( + excluded_from_statistics_reason__isnull=False + ).last() + + return latest_decision.licence.id if latest_decision.licence else None + - def get_decision_made_at(self, case): - if case.decision not in LicenceDecisionType.decisions(): - raise ValueError(f"Unknown decision type `{case.decision}`") # pragma: no cover +class ApplicationSerializer(serializers.ModelSerializer): + licence_type = serializers.CharField(source="case_type.reference") + sub_type = serializers.SerializerMethodField() + status = serializers.CharField(source="status.status") + processing_time = serializers.IntegerField(source="sla_days") + first_closed_at = serializers.SerializerMethodField() - return ( - case.licence_decisions.filter( - decision=case.decision, - ) - .earliest("created_at") - .created_at + class Meta: + model = StandardApplication + fields = ( + "id", + "licence_type", + "reference_code", + "sub_type", + "status", + "processing_time", + "first_closed_at", ) + + def get_sub_type(self, application): + if any(g.is_good_incorporated or g.is_onward_incorporated for g in application.goods.all()): + return "incorporation" + + if application.export_type: + return application.export_type + + raise Exception("Unknown sub-type") + + def get_first_closed_at(self, application): + if application.licence_decisions.exists(): + earliest = None + for licence_decision in application.licence_decisions.all(): + if not earliest: + earliest = licence_decision.created_at + continue + if licence_decision.created_at < earliest: + earliest = licence_decision.created_at + return earliest + + first_closed_status = self.context["first_closed_statuses"].get(str(application.pk)) + if first_closed_status: + return first_closed_status + + return None + + +class CountrySerializer(serializers.ModelSerializer): + code = serializers.CharField(source="id") + + class Meta: + model = Country + fields = ( + "code", + "name", + ) + + +class DestinationSerializer(serializers.ModelSerializer): + country_code = serializers.CharField(source="party.country.id") + type = serializers.CharField(source="party.type") + + class Meta: + model = PartyOnApplication + fields = ( + "country_code", + "application_id", + "type", + ) + + +class GoodSerializer(serializers.ModelSerializer): + class Meta: + model = GoodOnApplication + fields = ( + "id", + "application_id", + "quantity", + "unit", + "value", + ) + + +class GoodRatingSerializer(serializers.ModelSerializer): + good_id = serializers.UUIDField() + + class Meta: + model = ControlListEntry + fields = ( + "good_id", + "rating", + ) + + +class GoodOnLicenceSerializer(serializers.ModelSerializer): + class Meta: + model = GoodOnLicence + fields = ( + "id", + "good_id", + "licence_id", + ) + + +class GoodDescriptionSerializer(serializers.ModelSerializer): + description = serializers.CharField(source="name") + good_id = serializers.UUIDField() + + class Meta: + model = ReportSummary + fields = ( + "description", + "good_id", + ) + + +class LicenceRefusalCriteriaSerializer(serializers.ModelSerializer): + criteria = serializers.CharField(source="display_value") + licence_decision_id = serializers.UUIDField(source="licence_decisions_id") + + class Meta: + model = DenialReason + fields = ("criteria", "licence_decision_id") + + +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 UnitSerializer(serializers.Serializer): + code = serializers.CharField() + description = serializers.CharField() + + +class StatusSerializer(serializers.Serializer): + status = serializers.CharField() + name = serializers.CharField() + is_closed = serializers.SerializerMethodField() + + def get_is_closed(self, status): + return CaseStatusEnum.is_closed(status["status"]) diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index 2e255702d..a8fcb88ba 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -76,7 +76,11 @@ def gov_user_permissions(): def lu_case_officer(gov_user, gov_user_permissions): gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) gov_user.role.permissions.set( - [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.MANAGE_LICENCE_DURATION.name] + [ + GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, + GovPermissions.MANAGE_LICENCE_DURATION.name, + GovPermissions.REOPEN_CLOSED_CASES.name, + ] ) gov_user.save() return gov_user @@ -99,6 +103,11 @@ def gov_headers(gov_user): return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.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)} + + @pytest.fixture() def lu_sr_manager_headers(lu_senior_manager): return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_senior_manager.baseuser_ptr)} 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 887aa903c..0163de1f7 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 @@ -10,11 +10,13 @@ ) from unittest import mock -from api.cases.enums import AdviceType, LicenceDecisionType +from api.applications.models import StandardApplication +from api.cases.enums import AdviceLevel, AdviceType, LicenceDecisionType from api.cases.models import LicenceDecision from api.licences.enums import LicenceStatus from api.licences.models import Licence from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus scenarios("../scenarios/licence_decisions.feature") @@ -40,7 +42,7 @@ def standard_draft_licence_created(standard_draft_licence): def draft_licence_not_included_in_extract(draft_licence, unpage_data, licence_decisions_list_url): licences = unpage_data(licence_decisions_list_url) - assert draft_licence.reference_code not in [item["reference_code"] for item in licences] + assert str(draft_licence.case.id) not in [item["application_id"] for item in licences] @given("a standard licence is cancelled", target_fixture="cancelled_licence") @@ -55,39 +57,40 @@ def standard_licence_is_cancelled(standard_licence): def cancelled_licence_not_included_in_extract(cancelled_licence, unpage_data, licence_decisions_list_url): licences = unpage_data(licence_decisions_list_url) - assert cancelled_licence.reference_code not in [item["reference_code"] for item in licences] + assert str(cancelled_licence.case.id) not in [item["application_id"] for item in licences] @then("I see issued licence is included in the extract") def licence_included_in_extract(issued_licence, unpage_data, licence_decisions_list_url): licences = unpage_data(licence_decisions_list_url) - assert issued_licence.reference_code in [item["reference_code"] for item in licences] + assert str(issued_licence.case.id) in [item["application_id"] for item in licences] @then("I see refused case is included in the extract") def refused_case_included_in_extract(refused_case, unpage_data, licence_decisions_list_url): licences = unpage_data(licence_decisions_list_url) - assert refused_case.reference_code in [item["reference_code"] for item in licences] + assert str(refused_case.id) in [item["application_id"] for item in licences] @given("a case is ready to be finalised", target_fixture="case_with_final_advice") def case_ready_to_be_finalised(standard_case_with_final_advice): - assert standard_case_with_final_advice.status.status == CaseStatusEnum.UNDER_FINAL_REVIEW + assert standard_case_with_final_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.UNDER_FINAL_REVIEW) return standard_case_with_final_advice @given("a case is ready to be refused", target_fixture="case_with_refused_advice") def case_ready_to_be_refused(standard_case_with_refused_advice): - assert standard_case_with_refused_advice.status.status == CaseStatusEnum.UNDER_FINAL_REVIEW + assert standard_case_with_refused_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.UNDER_FINAL_REVIEW) return standard_case_with_refused_advice @when("the licence for the case is approved") def licence_for_case_is_approved(client, gov_headers, case_with_final_advice): + application = StandardApplication.objects.get(id=case_with_final_advice.id) data = {"action": AdviceType.APPROVE, "duration": 24} - for good_on_app in case_with_final_advice.goods.all(): + for good_on_app in application.goods.all(): data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) data[f"value-{good_on_app.id}"] = str(good_on_app.value) @@ -131,22 +134,25 @@ def case_officer_issues_licence(client, gov_headers, case_with_final_advice): assert response.status_code == 201 case_with_final_advice.refresh_from_db() - assert case_with_final_advice.status.status == CaseStatusEnum.FINALISED + assert case_with_final_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) assert case_with_final_advice.sub_status.name == "Approved" response = response.json() assert response["licence"] is not None licence = Licence.objects.get(id=response["licence"]) - assert licence.status == LicenceStatus.ISSUED + assert licence.status in [LicenceStatus.ISSUED, LicenceStatus.REINSTATED] + return licence + + +@then("a licence decision with an issued decision is created") +def licence_decision_issued_created(issued_licence): assert LicenceDecision.objects.filter( - case=case_with_final_advice, + case=issued_licence.case, decision=LicenceDecisionType.ISSUED, ).exists() - return licence - @when("the licence for the case is refused") def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): @@ -184,7 +190,7 @@ def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): assert response.status_code == 201 case_with_refused_advice.refresh_from_db() - assert case_with_refused_advice.status.status == CaseStatusEnum.FINALISED + assert case_with_refused_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) assert case_with_refused_advice.sub_status.name == "Refused" assert LicenceDecision.objects.filter( @@ -195,6 +201,14 @@ def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): return case_with_refused_advice +@then("a licence decision with refused decision is created") +def licence_decision_refused_created(refused_case): + assert LicenceDecision.objects.filter( + case=refused_case, + decision=LicenceDecisionType.REFUSED, + ).exists() + + @when("case officer revokes issued licence", target_fixture="revoked_licence") def case_officer_revokes_licence(client, lu_sr_manager_headers, issued_licence): url = reverse("licences:licence_details", kwargs={"pk": str(issued_licence.pk)}) @@ -220,4 +234,67 @@ def revoked_licence_decision_included_in_extract(licence_decisions, revoked_lice all_revoked_licences = [item for item in licence_decisions if item["decision"] == "revoked"] - assert revoked_licence.case.reference_code in [item["reference_code"] for item in all_revoked_licences] + assert str(revoked_licence.case.id) in [item["application_id"] for item in all_revoked_licences] + + +def case_reopen_prepare_to_finalise(client, lu_case_officer_headers, case): + url = reverse( + "caseworker_applications:change_status", + kwargs={ + "pk": str(case.pk), + }, + ) + response = client.post( + url, {"status": CaseStatusEnum.REOPENED_FOR_CHANGES}, content_type="application/json", **lu_case_officer_headers + ) + assert response.status_code == 200 + case.refresh_from_db() + assert case.status == CaseStatus.objects.get(status=CaseStatusEnum.REOPENED_FOR_CHANGES) + + response = client.post( + url, {"status": CaseStatusEnum.UNDER_FINAL_REVIEW}, content_type="application/json", **lu_case_officer_headers + ) + assert response.status_code == 200 + case.refresh_from_db() + assert case.status == CaseStatus.objects.get(status=CaseStatusEnum.UNDER_FINAL_REVIEW) + + return case + + +@when("an appeal is successful and case is ready to be finalised", target_fixture="case_with_final_advice") +def case_ready_to_be_finalised_after_an_appeal(client, lu_case_officer_headers, refused_case): + # Appeal handling is a manual process and we need to remove previous final advice + # before the case can be finalised again + assert refused_case.status.status == CaseStatusEnum.FINALISED + + refused_case.advice.filter(level=AdviceLevel.FINAL).update( + type=AdviceType.APPROVE, + text="issued on appeal", + ) + + successful_appeal_case = case_reopen_prepare_to_finalise(client, lu_case_officer_headers, refused_case) + + return successful_appeal_case + + +@when("a licence needs amending and case is ready to be finalised", target_fixture="case_with_final_advice") +def case_ready_to_be_finalised_after_amending_licence(client, lu_case_officer_headers, issued_licence): + 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.APPROVE, + text="re-issuing licence", + ) + + case_with_final_advice = case_reopen_prepare_to_finalise(client, lu_case_officer_headers, case_with_final_advice) + + return case_with_final_advice + + +@then("a licence decision with an issued_on_appeal decision is created") +def licence_decision_issued_on_appeal_created(issued_licence): + all_licence_decisions = LicenceDecision.objects.filter(case=issued_licence.case) + + assert all_licence_decisions.first().decision == LicenceDecisionType.REFUSED + assert all(item.decision == LicenceDecisionType.ISSUED_ON_APPEAL for item in all_licence_decisions[1:]) diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature index a283968a3..13f45da9f 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature @@ -14,6 +14,7 @@ Scenario: Issued licence decision is created when licence is issued When the licence for the case is approved And case officer generates licence documents And case officer issues licence for this case + Then a licence decision with an issued decision is created When I fetch all licence decisions Then I see issued licence is included in the extract @@ -22,6 +23,7 @@ Scenario: Refused licence decision is created when licence is refused When the licence for the case is refused And case officer generates refusal documents And case officer refuses licence for this case + Then a licence decision with refused decision is created When I fetch all licence decisions Then I see refused case is included in the extract @@ -34,3 +36,40 @@ Scenario: Revoked licence decision is created when licence is revoked When case officer revokes issued licence And I fetch all licence decisions Then I see revoked licence is included in the extract + +Scenario: Licence issued after an appeal is recorded as issued_on_appeal + Given a case is ready to be refused + When the licence for the case is refused + And case officer generates refusal documents + And case officer refuses licence for this case + When I fetch all licence decisions + Then I see refused case is included in the extract + When an appeal is successful and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued_on_appeal decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract + +Scenario: Licence issued after an appeal and re-issued again + Given a case is ready to be refused + When the licence for the case is refused + And case officer generates refusal documents + And case officer refuses licence for this case + When I fetch all licence decisions + Then I see refused case is included in the extract + When an appeal is successful and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued_on_appeal decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract + When a licence needs amending and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued_on_appeal decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index a8f6055a1..f8c3c71e1 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -10,3 +10,69 @@ views.LicenceDecisionViewSet, basename="dw-licence-decisions", ) + +router_v2.register( + "applications", + views.ApplicationViewSet, + basename="dw-applications", +) + +router_v2.register( + "countries", + views.CountryViewSet, + basename="dw-countries", +) + +router_v2.register( + "destinations", + views.DestinationViewSet, + basename="dw-destinations", +) + +router_v2.register( + "goods", + views.GoodViewSet, + basename="dw-goods", +) + +router_v2.register( + "goods-ratings", + views.GoodRatingViewSet, + basename="dw-goods-ratings", +) + +router_v2.register( + "goods-on-licences", + views.GoodOnLicenceViewSet, + basename="dw-goods-on-licences", +) + +router_v2.register( + "goods-descriptions", + views.GoodDescriptionViewSet, + basename="dw-goods-descriptions", +) + +router_v2.register( + "licences-refusals-criteria", + views.LicenceRefusalCriteriaViewSet, + basename="dw-licences-refusals-criteria", +) + +router_v2.register( + "footnotes", + views.FootnoteViewSet, + basename="dw-footnotes", +) + +router_v2.register( + "units", + views.UnitViewSet, + basename="dw-units", +) + +router_v2.register( + "statuses", + views.StatusViewSet, + basename="dw-statuses", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index ce28e0dd8..1e3c9ef1e 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,18 +1,55 @@ +import itertools + from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination +from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework_csv.renderers import PaginatedCSVRenderer -from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import F -from api.cases.models import Case +from django.db.models import ( + F, + Min, + Q, +) +from django.db.models.query import QuerySet +from django.http import Http404 + +from api.applications.models import ( + GoodOnApplication, + PartyOnApplication, + StandardApplication, +) +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit +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, + CountrySerializer, + DestinationSerializer, + FootnoteSerializer, + GoodDescriptionSerializer, + GoodOnLicenceSerializer, + GoodSerializer, + GoodRatingSerializer, LicenceDecisionSerializer, - LicenceDecisionType, + LicenceRefusalCriteriaSerializer, + StatusSerializer, + UnitSerializer, ) +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.denial_reasons.models import DenialReason +from api.staticdata.report_summaries.models import ReportSummary +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.units.enums import Units class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -23,35 +60,158 @@ def paginate_queryset(self, queryset, request, view=None): return super().paginate_queryset(queryset, request, view) -class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): +class BaseViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + +class LicenceDecisionViewSet(BaseViewSet): serializer_class = LicenceDecisionSerializer + queryset = ( + LicenceDecision.objects.filter(previous_decision__isnull=True) + .exclude(excluded_from_statistics_reason__isnull=False) + .prefetch_related("case__licence_decisions", "case__licence_decisions__licence") + .select_related("case") + .order_by("-case__reference_code") + ) - def get_queryset(self): - queryset = ( - ( - Case.objects.filter( - licence_decisions__decision__in=[LicenceDecisionType.ISSUED, LicenceDecisionType.REFUSED], - ) - .annotate( - unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), - ) - .filter(unique_decisions__len=1) - .annotate(decision=F("unique_decisions__0")) - ) - .union( - Case.objects.filter( - licence_decisions__decision__in=[LicenceDecisionType.REVOKED], - ) - .annotate( - unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), - ) - .filter(unique_decisions__len=1) - .annotate(decision=F("unique_decisions__0")), - all=True, + +class ApplicationViewSet(BaseViewSet): + serializer_class = ApplicationSerializer + queryset = ( + StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) + .select_related("case_type", "status") + .prefetch_related("goods", "licence_decisions") + ) + + def get_first_closed_statuses(self, queryset): + status_map = dict(CaseStatusEnum.choices) + closed_statuses = list( + itertools.chain.from_iterable((status, status_map[status]) for status in CaseStatusEnum.closed_statuses()) + ) + application_ids = [] + if isinstance(queryset, list): + application_ids = [str(s.pk) for s in queryset] + elif isinstance(queryset, QuerySet): + application_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)] + + first_closed_status_updates = ( + Audit.objects.filter( + target_object_id__in=application_ids, + verb=AuditType.UPDATED_STATUS, + payload__status__new__in=closed_statuses, ) - .order_by("-reference_code") + .annotate(first_closed_date=Min("created_at")) + .values_list("target_object_id", "first_closed_date") ) - return queryset + + return dict(first_closed_status_updates) + + def get_serializer(self, *args, **kwargs): + serializer_class = self.get_serializer_class() + + context = self.get_serializer_context() + + if args and isinstance(args[0], (QuerySet, list)) and kwargs.get("many", False): + context["first_closed_statuses"] = self.get_first_closed_statuses(args[0]) + elif args and isinstance(args[0], StandardApplication): + context["first_closed_statuses"] = self.get_first_closed_statuses([args[0]]) + kwargs.setdefault("context", context) + + return serializer_class(*args, **kwargs) + + +class CountryViewSet(BaseViewSet): + serializer_class = CountrySerializer + queryset = Country.objects.all().order_by("id", "name") + + +class DestinationViewSet(BaseViewSet): + serializer_class = DestinationSerializer + queryset = ( + PartyOnApplication.objects.filter(deleted_at__isnull=True) + .exclude(application__status__status=CaseStatusEnum.DRAFT) + .select_related("party", "party__country") + ) + + +class GoodViewSet(BaseViewSet): + serializer_class = GoodSerializer + queryset = GoodOnApplication.objects.exclude(application__status__status=CaseStatusEnum.DRAFT) + + +class GoodRatingViewSet(BaseViewSet): + serializer_class = GoodRatingSerializer + queryset = ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) + + +class GoodDescriptionViewSet(BaseViewSet): + serializer_class = GoodDescriptionSerializer + queryset = ( + ReportSummary.objects.select_related("prefix", "subject") + .prefetch_related("goods_on_application") + .exclude(goods_on_application__isnull=True) + .annotate(good_id=F("goods_on_application__id")) + ) + + +class GoodOnLicenceViewSet(BaseViewSet): + serializer_class = GoodOnLicenceSerializer + queryset = GoodOnLicence.objects.exclude( + licence__case__status__status=CaseStatusEnum.DRAFT, + licence__status=LicenceStatus.DRAFT, + ) + + +class LicenceRefusalCriteriaViewSet(BaseViewSet): + serializer_class = LicenceRefusalCriteriaSerializer + queryset = DenialReason.objects.exclude(licencedecision__denial_reasons__isnull=True).annotate( + licence_decisions_id=F("licencedecision__id") + ) + + +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 UnitViewSet(viewsets.ViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + def list(self, request): + units = [{"code": code, "description": description} for code, description in Units.choices] + return Response(UnitSerializer(units, many=True).data) + + def retrieve(self, request, pk): + units = dict(Units.choices) + try: + description = units[pk] + except KeyError: + raise Http404() + return Response(UnitSerializer({"code": pk, "description": description}).data) + + +class StatusViewSet(viewsets.ViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + def list(self, request): + statuses = [{"status": status, "name": name} for status, name in CaseStatusEnum.choices] + return Response(StatusSerializer(statuses, many=True).data) + + def retrieve(self, request, pk): + statuses = dict(CaseStatusEnum.choices) + try: + name = statuses[pk] + except KeyError: + raise Http404() + return Response(StatusSerializer({"status": pk, "name": name}).data) diff --git a/api/staticdata/statuses/enums.py b/api/staticdata/statuses/enums.py index 4fb7f5af9..a8cfe7011 100644 --- a/api/staticdata/statuses/enums.py +++ b/api/staticdata/statuses/enums.py @@ -60,6 +60,16 @@ class CaseStatusEnum: SUPERSEDED_BY_EXPORTER_EDIT, ] + _closed_statuses = [ + CLOSED, + DEREGISTERED, + FINALISED, + REGISTERED, + REVOKED, + SURRENDERED, + WITHDRAWN, + ] + # Cases with these statuses can be operated upon by caseworkers _caseworker_operable_statuses = [ APPEAL_FINAL_REVIEW, @@ -225,6 +235,10 @@ def is_editable(cls, status): def is_terminal(cls, status): return status in cls._terminal_statuses + @classmethod + def is_closed(cls, status): + return status in cls._closed_statuses + @classmethod def is_system_status(cls, status): return status in cls._system_status @@ -269,6 +283,10 @@ def can_invoke_major_edit(cls, status): def terminal_statuses(cls): return cls._terminal_statuses + @classmethod + def closed_statuses(cls): + return cls._closed_statuses + @classmethod def as_list(cls): from api.staticdata.statuses.models import CaseStatus