Skip to content

Commit

Permalink
Merge pull request #846 from uktrade/LTD-1171-Countersign-approve-API…
Browse files Browse the repository at this point in the history
…-changes

LTD-1171: Add endpoint to Countersign advice
  • Loading branch information
saruniitr authored Nov 8, 2021
2 parents ca4605e + bc4dd04 commit 94db5df
Show file tree
Hide file tree
Showing 10 changed files with 394 additions and 2 deletions.
18 changes: 18 additions & 0 deletions api/applications/serializers/advice.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ class AdviceViewSerializer(serializers.Serializer):
ultimate_end_user = serializers.UUIDField(source="ultimate_end_user_id")
consignee = serializers.UUIDField(source="consignee_id")
third_party = serializers.UUIDField(source="third_party_id")
countersigned_by = PrimaryKeyRelatedSerializerField(
queryset=GovUser.objects.all(), serializer=GovUserListSerializer
)
countersign_comments = serializers.CharField()


class AdviceCreateSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -153,6 +157,20 @@ def _footnote_fields_setup(self):
self.initial_data[i]["footnote_required"] = None


class CountersignAdviceListSerializer(serializers.ListSerializer):
def update(self, instances, validated_data):
instance_map = {index: instance for index, instance in enumerate(instances)}
result = [self.child.update(instance_map[index], data) for index, data in enumerate(validated_data)]
return result


class CountersignAdviceSerializer(serializers.ModelSerializer):
class Meta:
model = Advice
fields = ("id", "countersigned_by", "countersign_comments")
list_serializer_class = CountersignAdviceListSerializer


class CountryWithFlagsSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
Expand Down
1 change: 1 addition & 0 deletions api/audit_trail/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class AuditType(LiteEnum):
LICENCE_UPDATED_STATUS = autostr()
DOCUMENT_ON_ORGANISATION_CREATE = autostr()
REPORT_SUMMARY_UPDATED = autostr()
COUNTERSIGN_ADVICE = autostr()

def human_readable(self):
"""
Expand Down
246 changes: 246 additions & 0 deletions api/audit_trail/migrations/0004_auto_20211101_1242.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions api/audit_trail/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,5 @@ def format_payload(audit_type, payload):
AuditType.LICENCE_UPDATED_STATUS: strings.Audit.LICENCE_UPDATED_STATUS,
AuditType.DOCUMENT_ON_ORGANISATION_CREATE: "added {document_type} '{file_name}' to organization",
AuditType.REPORT_SUMMARY_UPDATED: "updated ARS for {good_name} from {old_report_summary} to {report_summary}",
AuditType.COUNTERSIGN_ADVICE: "countersigned advice ids: {advice_ids}",
}
35 changes: 35 additions & 0 deletions api/cases/migrations/0051_auto_20211028_1327.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 3.1.8 on 2021-11-02 10:28

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("users", "0001_squashed_0018_baseuser_phone_number"),
("cases", "0050_departmentsla"),
]

operations = [
migrations.AddField(
model_name="advice",
name="countersign_comments",
field=models.TextField(
blank=True,
default="",
help_text="Reasons provided by the countersigner when they agree/disagree with the advice during countersigning",
),
),
migrations.AddField(
model_name="advice",
name="countersigned_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="countersigned_by",
to="users.govuser",
),
),
]
8 changes: 8 additions & 0 deletions api/cases/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,14 @@ class Advice(TimestampableModel):
pv_grading = models.CharField(choices=PvGrading.choices, null=True, max_length=30)
# This is to store the collated security grading(s) for display purposes
collated_pv_grading = models.TextField(default=None, blank=True, null=True)
countersign_comments = models.TextField(
blank=True,
default="",
help_text="Reasons provided by the countersigner when they agree/disagree with the advice during countersigning",
)
countersigned_by = models.ForeignKey(
GovUser, on_delete=models.DO_NOTHING, related_name="countersigned_by", blank=True, null=True
)

objects = AdviceManager()

Expand Down
50 changes: 50 additions & 0 deletions api/cases/tests/test_create_advice.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,53 @@ def test_can_create_advice_with_footnote_required(self):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response_data["footnote"], "footnote")
self.assertEqual(Advice.objects.filter(footnote_required=True, footnote=data["footnote"]).count(), 1)


class CountersignAdviceTests(DataTestClient):
def setUp(self):
super().setUp()
self.application = self.create_draft_standard_application(self.organisation)
self.case = self.submit_application(self.application)

self.url = reverse("cases:countersign_advice", kwargs={"pk": self.case.id})

def test_countersign_advice_terminal_status_failure(self):
"""Ensure we cannot countersign a case that is in one of the terminal state"""
case_url = reverse("cases:case", kwargs={"pk": self.case.id})
data = {"status": CaseStatusEnum.WITHDRAWN, "note": "test"}
response = self.client.patch(case_url, data, **self.gov_headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)

response = self.client.put(self.url, **self.gov_headers, data=[])
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_countersign_advice_success(self):
"""Ensure we can countersign a case with advice given by multiple users and it
emits an audit log"""
all_advice = [
Advice.objects.create(
**{
"user": self.gov_user,
"good": self.application.goods.first().good,
"team": self.team,
"case": self.case,
"note": f"Advice {i}",
}
)
for i in range(4)
]

data = [
{
"id": advice.id,
"countersigned_by": self.gov_user.baseuser_ptr.id,
"comments": "Agree with recommendation",
}
for advice in all_advice
]

response = self.client.put(self.url, **self.gov_headers, data=data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
audit_qs = Audit.objects.filter(verb=AuditType.COUNTERSIGN_ADVICE)
self.assertEqual(audit_qs.count(), 1)
self.assertEqual(audit_qs.first().actor, self.gov_user)
2 changes: 2 additions & 0 deletions api/cases/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,6 @@
path("<uuid:pk>/reissue-ogl/", case_actions.OpenGeneralLicenceReissue.as_view(), name="reissue_ogl"),
path("<uuid:pk>/rerun-routing-rules/", case_actions.RerunRoutingRules.as_view(), name="rerun_routing_rules"),
path("<uuid:pk>/review-date/", views.NextReviewDate.as_view(), name="review_date"),
# Advice2.0
path("<uuid:pk>/countersign-advice/", views.CountersignAdvice.as_view(), name="countersign_advice"),
]
33 changes: 31 additions & 2 deletions api/cases/views/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from rest_framework.exceptions import ParseError
from rest_framework.generics import ListCreateAPIView, UpdateAPIView
from rest_framework.views import APIView

from api.applications.serializers.advice import CountryWithFlagsSerializer
from api.applications.serializers.advice import CountersignAdviceSerializer, CountryWithFlagsSerializer
from api.audit_trail import service as audit_trail_service
from api.audit_trail.enums import AuditType
from api.cases.enums import (
Expand Down Expand Up @@ -89,6 +88,7 @@
from api.staticdata.statuses.enums import CaseStatusEnum
from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status
from api.users.libraries.get_user import get_user_by_pk
from lite_content.lite_api import strings


class CaseDetail(APIView):
Expand Down Expand Up @@ -931,3 +931,32 @@ def put(self, request, pk):
)

return JsonResponse(data={}, status=status.HTTP_200_OK)


class CountersignAdvice(APIView):
authentication_classes = (GovAuthentication,)

def put(self, request, **kwargs):
case = get_case(kwargs["pk"])

if CaseStatusEnum.is_terminal(case.status.status):
return JsonResponse(
data={"errors": [strings.Applications.Generic.TERMINAL_CASE_CANNOT_PERFORM_OPERATION_ERROR]},
status=status.HTTP_400_BAD_REQUEST,
)

data = request.data
advice_ids = [advice["id"] for advice in data]

serializer = CountersignAdviceSerializer(
Advice.objects.filter(id__in=advice_ids), data=data, many=True, partial=True
)
if not serializer.is_valid():
return JsonResponse({"errors": serializer.errors}, status=status.HTTP_400_BAD_REQUEST)

serializer.save()
audit_trail_service.create(
actor=request.user, verb=AuditType.COUNTERSIGN_ADVICE, target=case, payload={"advice_ids": advice_ids},
)

return JsonResponse({"advice": serializer.data}, status=status.HTTP_200_OK)
2 changes: 2 additions & 0 deletions api/data_workspace/tests/test_advice_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,7 @@ def test_advice(self):
"consignee",
"third_party",
"denial_reasons",
"countersigned_by",
"countersign_comments",
}
assert set(last_result.keys()) == expected_fields

0 comments on commit 94db5df

Please sign in to comment.