diff --git a/api/cases/helpers.py b/api/cases/helpers.py index 208d14b526..3bfeb801d5 100644 --- a/api/cases/helpers.py +++ b/api/cases/helpers.py @@ -1,3 +1,6 @@ +from datetime import timedelta + +from api.common.dates import is_bank_holiday, is_weekend from api.cases.enums import CaseTypeReferenceEnum from api.staticdata.statuses.enums import CaseStatusEnum from api.users.models import GovUser, GovNotification @@ -73,3 +76,8 @@ def can_set_status(case, status): return False return True + + +def working_days_in_range(start_date, end_date): + dates_in_range = [start_date + timedelta(n) for n in range((end_date - start_date).days)] + return len([date for date in dates_in_range if (not is_bank_holiday(date) and not is_weekend(date))]) diff --git a/api/cases/models.py b/api/cases/models.py index f1cb2e0de8..a0c70d545b 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -26,6 +26,7 @@ AdviceLevel, EnforcementXMLEntityTypes, ) +from api.cases.helpers import working_days_in_range from api.cases.libraries.reference_code import generate_reference_code from api.cases.managers import CaseManager, CaseReferenceCodeManager, AdviceManager from api.common.models import TimestampableModel, CreatedAt @@ -627,7 +628,6 @@ def is_query_closed(self): @queryable_property def is_manually_closed(self): - if self.responded_by_user and self.responded_by_user.type == UserType.INTERNAL: return True else: @@ -638,6 +638,12 @@ def is_manually_closed(self): def is_query_closed(self, lookup, value): return ~Q(responded_at__isnull=value) + @property + def open_working_days(self): + start_date = self.created_at + end_date = self.responded_at if self.responded_at else timezone.now() + return working_days_in_range(start_date=start_date, end_date=end_date) + notifications = GenericRelation(ExporterNotification, related_query_name="ecju_query") def save(self, *args, **kwargs): diff --git a/api/cases/serializers.py b/api/cases/serializers.py index f104057802..de07bc1a29 100644 --- a/api/cases/serializers.py +++ b/api/cases/serializers.py @@ -576,6 +576,7 @@ class Meta: "documents", "is_query_closed", "is_manually_closed", + "open_working_days", ) def get_raised_by_user_name(self, instance): diff --git a/api/cases/tests/test_case_ecju_queries.py b/api/cases/tests/test_case_ecju_queries.py index fa5be3c2f6..4230a3147c 100644 --- a/api/cases/tests/test_case_ecju_queries.py +++ b/api/cases/tests/test_case_ecju_queries.py @@ -1,4 +1,5 @@ from unittest import mock +from datetime import timedelta from django.urls import reverse from django.utils import timezone @@ -6,6 +7,7 @@ from faker import Faker from parameterized import parameterized from rest_framework import status +from freezegun import freeze_time from api.applications.models import BaseApplication from api.audit_trail.enums import AuditType @@ -224,6 +226,127 @@ def test_ecju_query_open_query_count_responded_return_zero(self): self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertEqual(0, response_data["count"]) + @parameterized.expand( + [ + ( + { + "year": 2022, + "month": 11, + "day": 30, + "hour": 9, + "minute": 50, + "tzinfo": timezone.utc, + }, + 291, + ), + ( + { + "year": 2023, + "month": 12, + "day": 15, + "hour": 13, + "minute": 37, + "tzinfo": timezone.utc, + }, + 28, + ), + ( + { + "year": 2024, + "month": 1, + "day": 1, + "hour": 12, + "minute": 30, + "tzinfo": timezone.utc, + }, + 19, + ), + ({"year": 2024, "month": 1, "day": 22, "hour": 15, "minute": 40, "tzinfo": timezone.utc}, 5), + ] + ) + @freeze_time("2024-01-29 15:00:00") + def test_ecju_query_shows_correct_open_working_days_for_open_query( + self, created_at_datetime_kwargs, expected_working_days + ): + case = self.create_standard_application_case(self.organisation) + created_at_datetime = timezone.datetime(**created_at_datetime_kwargs) + ecju_query = EcjuQueryFactory( + question="this is the question", + response=None, + responded_at=None, + case=case, + created_at=created_at_datetime, + ) + + url = reverse("cases:case_ecju_queries", kwargs={"pk": case.id}) + + response = self.client.get(url, **self.gov_headers) + response_data = response.json() + + assert response_data["ecju_queries"][0]["open_working_days"] == expected_working_days + + @parameterized.expand( + [ + ( + { + "year": 2022, + "month": 11, + "day": 30, + "hour": 9, + "minute": 50, + "tzinfo": timezone.utc, + }, + 365, + 252, + ), + ( + { + "year": 2023, + "month": 12, + "day": 15, + "hour": 13, + "minute": 37, + "tzinfo": timezone.utc, + }, + 30, + 18, + ), + ({"year": 2024, "month": 1, "day": 22, "hour": 15, "minute": 40, "tzinfo": timezone.utc}, 7, 5), + ( + { + "year": 2024, + "month": 1, + "day": 28, + "hour": 12, + "minute": 6, + "tzinfo": timezone.utc, + }, + 1, + 0, + ), + ], + ) + def test_ecju_query_shows_correct_open_working_days_for_closed_query( + self, created_at_datetime_kwargs, calendar_days, expected_working_days + ): + case = self.create_standard_application_case(self.organisation) + created_at_datetime = timezone.datetime(**created_at_datetime_kwargs) + responded_at_datetime = created_at_datetime + timedelta(days=calendar_days) + ecju_query = EcjuQueryFactory( + question="this is the question", + response="some response text", + responded_at=responded_at_datetime, + case=case, + created_at=created_at_datetime, + ) + + url = reverse("cases:case_ecju_queries", kwargs={"pk": case.id}) + + response = self.client.get(url, **self.gov_headers) + response_data = response.json() + + assert response_data["ecju_queries"][0]["open_working_days"] == expected_working_days + class ECJUQueriesCreateTest(DataTestClient): @parameterized.expand([ECJUQueryType.ECJU, ECJUQueryType.PRE_VISIT_QUESTIONNAIRE, ECJUQueryType.COMPLIANCE_ACTIONS]) diff --git a/api/cases/tests/test_helpers.py b/api/cases/tests/test_helpers.py index 6aa60b73d6..f85b5e651a 100644 --- a/api/cases/tests/test_helpers.py +++ b/api/cases/tests/test_helpers.py @@ -1 +1,54 @@ # TODO; test notify_ecju_query in total isolation + +import datetime +from parameterized import parameterized + +from api.cases.helpers import working_days_in_range + + +@parameterized.expand( + [ + ( + { + "year": 2022, + "month": 11, + "day": 30, + "hour": 9, + "minute": 50, + "tzinfo": datetime.timezone.utc, + }, + 365, + 252, + ), + ( + { + "year": 2023, + "month": 12, + "day": 15, + "hour": 13, + "minute": 37, + "tzinfo": datetime.timezone.utc, + }, + 30, + 18, + ), + ({"year": 2024, "month": 1, "day": 22, "hour": 15, "minute": 40, "tzinfo": datetime.timezone.utc}, 7, 5), + ( + { + "year": 2024, + "month": 1, + "day": 28, + "hour": 12, + "minute": 6, + "tzinfo": datetime.timezone.utc, + }, + 1, + 0, + ), + ], +) +def test_working_days_in_range(created_at_datetime_kwargs, calendar_days, expected_working_days): + start_date = datetime.datetime(**created_at_datetime_kwargs) + end_date = start_date + datetime.timedelta(days=calendar_days) + + assert working_days_in_range(start_date, end_date) == expected_working_days diff --git a/api/common/dates.py b/api/common/dates.py index 710e630e7e..1042b0190e 100644 --- a/api/common/dates.py +++ b/api/common/dates.py @@ -27,6 +27,15 @@ def is_weekend(date): def working_days_in_range(start_date, end_date): + """ + This function does not work as intended and should not be used, please see + LTD-4628 for more information. + + This is because of the predicate used in the list + comprehension, which incorrectly returns True early. An example fix is to replace the list comprehension + with `[date for date in dates_in_range if (not is_bank_holiday(date) and not is_weekend(date))]` + but this should be done and released at the same time as the other fixes in LTD-4628. + """ dates_in_range = [start_date + timedelta(n) for n in range((end_date - start_date).days)] return len([date for date in dates_in_range if not is_bank_holiday(date) or not is_weekend(date)])