From 5cf3712189a212209b45c25c40651bd6576c4ffe Mon Sep 17 00:00:00 2001 From: Matti Lamppu Date: Thu, 14 Nov 2024 12:02:37 +0200 Subject: [PATCH] Add application section recurring reservation mass cancel endpoint --- .../test_recurring_reservation/helpers.py | 11 +- .../test_cancel_series.py | 253 ++++++++++++++++++ .../test_cancel_series_permissions.py | 92 +++++++ .../api/graphql/extensions/error_codes.py | 2 + tilavarauspalvelu/api/graphql/mutations.py | 2 + tilavarauspalvelu/api/graphql/schema.py | 2 + .../types/application_section/mutations.py | 26 +- .../types/application_section/permissions.py | 6 + .../types/application_section/serializers.py | 106 +++++++- 9 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 tests/test_graphql_api/test_recurring_reservation/test_cancel_series.py create mode 100644 tests/test_graphql_api/test_recurring_reservation/test_cancel_series_permissions.py diff --git a/tests/test_graphql_api/test_recurring_reservation/helpers.py b/tests/test_graphql_api/test_recurring_reservation/helpers.py index 4ff8ee9a1..f9ad20945 100644 --- a/tests/test_graphql_api/test_recurring_reservation/helpers.py +++ b/tests/test_graphql_api/test_recurring_reservation/helpers.py @@ -20,7 +20,16 @@ CREATE_SERIES_MUTATION = build_mutation("createReservationSeries", "ReservationSeriesCreateMutation") UPDATE_SERIES_MUTATION = build_mutation("updateReservationSeries", "ReservationSeriesUpdateMutation") RESCHEDULE_SERIES_MUTATION = build_mutation("rescheduleReservationSeries", "ReservationSeriesRescheduleMutation") -DENY_SERIES_MUTATION = build_mutation("denyReservationSeries", "ReservationSeriesDenyMutation", fields="denied future") +DENY_SERIES_MUTATION = build_mutation( + "denyReservationSeries", + "ReservationSeriesDenyMutation", + fields="future denied", +) +CANCEL_SECTION_SERIES_MUTATION = build_mutation( + "cancelAllApplicationSectionReservations", + "ApplicationSectionReservationCancellationMutation", + fields="future cancelled", +) def get_minimal_series_data(reservation_unit: ReservationUnit, user: User, **overrides: Any) -> dict[str, Any]: diff --git a/tests/test_graphql_api/test_recurring_reservation/test_cancel_series.py b/tests/test_graphql_api/test_recurring_reservation/test_cancel_series.py new file mode 100644 index 000000000..36fa185c2 --- /dev/null +++ b/tests/test_graphql_api/test_recurring_reservation/test_cancel_series.py @@ -0,0 +1,253 @@ +import datetime + +import pytest +from freezegun import freeze_time + +from tests.factories import ( + AllocatedTimeSlotFactory, + ApplicationRoundFactory, + ReservationCancelReasonFactory, + UserFactory, +) +from tilavarauspalvelu.enums import ReservationStateChoice, ReservationTypeChoice +from utils.date_utils import local_date, local_datetime + +from .helpers import CANCEL_SECTION_SERIES_MUTATION, create_reservation_series + +# Applied to all tests +pytestmark = [ + pytest.mark.django_db, +] + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__cancel_whole_remaining(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"cancelled": 5, "future": 5} + assert reservation_series.reservations.count() == 9 + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__cancel_details_not_required(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"cancelled": 5, "future": 5} + assert reservation_series.reservations.count() == 9 + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__not_seasonal_type(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.NORMAL, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"cancelled": 0, "future": 5} + assert reservation_series.reservations.count() == 9 + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__paid(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__price=10, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"cancelled": 0, "future": 5} + assert reservation_series.reservations.count() == 9 + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__not_confirmed_state(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__state=ReservationStateChoice.REQUIRES_HANDLING, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"cancelled": 0, "future": 5} + assert reservation_series.reservations.count() == 9 + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__cancellation_rule(graphql): + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(days=1), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + # First future reservation is not cancelled since it's too soon according to the cancellation rule. + assert response.first_query_object == {"cancelled": 4, "future": 5} + assert reservation_series.reservations.count() == 9 + + future_reservations = reservation_series.reservations.filter(begin__date__gte=local_date()).iterator() + + reservation_1 = next(future_reservations) + assert reservation_1.begin.date() == datetime.date(2024, 1, 1) + assert reservation_1.state == ReservationStateChoice.CONFIRMED + + reservation_2 = next(future_reservations) + assert reservation_2.begin.date() == datetime.date(2024, 1, 8) + assert reservation_2.state == ReservationStateChoice.CANCELLED diff --git a/tests/test_graphql_api/test_recurring_reservation/test_cancel_series_permissions.py b/tests/test_graphql_api/test_recurring_reservation/test_cancel_series_permissions.py new file mode 100644 index 000000000..19fd59ede --- /dev/null +++ b/tests/test_graphql_api/test_recurring_reservation/test_cancel_series_permissions.py @@ -0,0 +1,92 @@ +import datetime + +import pytest +from freezegun import freeze_time + +from tests.factories import ( + AllocatedTimeSlotFactory, + ApplicationRoundFactory, + ReservationCancelReasonFactory, + UserFactory, +) +from tilavarauspalvelu.enums import ReservationTypeChoice, UserRoleChoice +from tilavarauspalvelu.models import ApplicationSection, ReservationCancelReason, User +from utils.date_utils import local_datetime + +from .helpers import CANCEL_SECTION_SERIES_MUTATION, create_reservation_series + +# Applied to all tests +pytestmark = [ + pytest.mark.django_db, +] + + +def create_data_for_cancellation() -> tuple[ReservationCancelReason, ApplicationSection, User]: + reason = ReservationCancelReasonFactory.create() + user = UserFactory.create() + + reservation_series = create_reservation_series( + user=user, + reservations__type=ReservationTypeChoice.SEASONAL, + reservations__price=0, + reservation_unit__cancellation_rule__can_be_cancelled_time_before=datetime.timedelta(), + ) + + application_round = ApplicationRoundFactory.create_in_status_results_sent() + allocation = AllocatedTimeSlotFactory.create( + reservation_unit_option__application_section__application__user=user, + reservation_unit_option__application_section__application__application_round=application_round, + ) + section = allocation.reservation_unit_option.application_section + + reservation_series.allocated_time_slot = allocation + reservation_series.save() + return reason, section, user + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__applicant(graphql): + reason, section, user = create_data_for_cancellation() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.force_login(user) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__superuser(graphql): + reason, section, _ = create_data_for_cancellation() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.login_with_superuser() + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.error_message() == "No permission to update." + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__cancel_section_series__general_admin(graphql): + reason, section, _ = create_data_for_cancellation() + + data = { + "pk": section.pk, + "cancelReason": reason.pk, + "cancelDetails": "Cancellation details", + } + + graphql.login_user_with_role(UserRoleChoice.ADMIN) + response = graphql(CANCEL_SECTION_SERIES_MUTATION, input_data=data) + + assert response.error_message() == "No permission to update." diff --git a/tilavarauspalvelu/api/graphql/extensions/error_codes.py b/tilavarauspalvelu/api/graphql/extensions/error_codes.py index b76d91c5b..a06bc5a8f 100644 --- a/tilavarauspalvelu/api/graphql/extensions/error_codes.py +++ b/tilavarauspalvelu/api/graphql/extensions/error_codes.py @@ -67,5 +67,7 @@ APPLICATION_ROUND_NOT_IN_ALLOCATION = "APPLICATION_ROUND_NOT_IN_ALLOCATION" APPLICATION_ROUND_NOT_HANDLED = "APPLICATION_ROUND_NOT_HANDLED" APPLICATION_ROUND_HAS_UNHANDLED_APPLICATIONS = "APPLICATION_ROUND_HAS_UNHANDLED_APPLICATIONS" +APPLICATION_ROUND_NOT_IN_RESULTS_SENT_STATE = "APPLICATION_ROUND_NOT_IN_RESULTS_SENT_STATE" +CANCEL_REASON_DOES_NOT_EXIST = "CANCEL_REASON_DOES_NOT_EXIST" DENY_REASON_DOES_NOT_EXIST = "DENY_REASON_DOES_NOT_EXIST" diff --git a/tilavarauspalvelu/api/graphql/mutations.py b/tilavarauspalvelu/api/graphql/mutations.py index 2e465152b..fc5086364 100644 --- a/tilavarauspalvelu/api/graphql/mutations.py +++ b/tilavarauspalvelu/api/graphql/mutations.py @@ -27,6 +27,7 @@ from .types.application_section.mutations import ( ApplicationSectionCreateMutation, ApplicationSectionDeleteMutation, + ApplicationSectionReservationCancellationMutation, ApplicationSectionUpdateMutation, RejectAllSectionOptionsMutation, RestoreAllSectionOptionsMutation, @@ -85,6 +86,7 @@ "ApplicationCreateMutation", "ApplicationSectionCreateMutation", "ApplicationSectionDeleteMutation", + "ApplicationSectionReservationCancellationMutation", "ApplicationSectionUpdateMutation", "ApplicationSendMutation", "ApplicationUpdateMutation", diff --git a/tilavarauspalvelu/api/graphql/schema.py b/tilavarauspalvelu/api/graphql/schema.py index 3b6d17303..859785b5b 100644 --- a/tilavarauspalvelu/api/graphql/schema.py +++ b/tilavarauspalvelu/api/graphql/schema.py @@ -25,6 +25,7 @@ ApplicationCreateMutation, ApplicationSectionCreateMutation, ApplicationSectionDeleteMutation, + ApplicationSectionReservationCancellationMutation, ApplicationSectionUpdateMutation, ApplicationSendMutation, ApplicationUpdateMutation, @@ -307,6 +308,7 @@ class Mutation(graphene.ObjectType): restore_all_section_options = RestoreAllSectionOptionsMutation.Field() reject_all_application_options = RejectAllApplicationOptionsMutation.Field() restore_all_application_options = RestoreAllApplicationOptionsMutation.Field() + cancel_all_application_section_reservations = ApplicationSectionReservationCancellationMutation.Field() set_application_round_handled = SetApplicationRoundHandledMutation.Field() set_application_round_results_sent = SetApplicationRoundResultsSentMutation.Field() # diff --git a/tilavarauspalvelu/api/graphql/types/application_section/mutations.py b/tilavarauspalvelu/api/graphql/types/application_section/mutations.py index ebff2d947..ab9497425 100644 --- a/tilavarauspalvelu/api/graphql/types/application_section/mutations.py +++ b/tilavarauspalvelu/api/graphql/types/application_section/mutations.py @@ -1,12 +1,21 @@ +from typing import Any + from graphene_django_extensions import CreateMutation, DeleteMutation, UpdateMutation from rest_framework.exceptions import ValidationError from tilavarauspalvelu.models import ApplicationSection from tilavarauspalvelu.typing import AnyUser -from .permissions import ApplicationSectionPermission, UpdateAllSectionOptionsPermission +from .permissions import ( + ApplicationSectionPermission, + ApplicationSectionReservationCancellationPermission, + UpdateAllSectionOptionsPermission, +) from .serializers import ( + ApplicationSectionReservationCancellationInputSerializer, + ApplicationSectionReservationCancellationOutputSerializer, ApplicationSectionSerializer, + CancellationOutput, RejectAllSectionOptionsSerializer, RestoreAllSectionOptionsSerializer, ) @@ -53,3 +62,18 @@ class RestoreAllSectionOptionsMutation(UpdateMutation): class Meta: serializer_class = RestoreAllSectionOptionsSerializer permission_classes = [UpdateAllSectionOptionsPermission] + + +class ApplicationSectionReservationCancellationMutation(UpdateMutation): + class Meta: + serializer_class = ApplicationSectionReservationCancellationInputSerializer + output_serializer_class = ApplicationSectionReservationCancellationOutputSerializer + permission_classes = [ApplicationSectionReservationCancellationPermission] + + @classmethod + def get_serializer_output(cls, instance: CancellationOutput) -> dict[str, Any]: + # `instance` take from serializer.save() return value, so overriding it "works" + return { + "future": instance["expected_cancellations"], + "cancelled": instance["actual_cancellations"], + } diff --git a/tilavarauspalvelu/api/graphql/types/application_section/permissions.py b/tilavarauspalvelu/api/graphql/types/application_section/permissions.py index 928f54510..86ab1bfef 100644 --- a/tilavarauspalvelu/api/graphql/types/application_section/permissions.py +++ b/tilavarauspalvelu/api/graphql/types/application_section/permissions.py @@ -60,3 +60,9 @@ def has_update_permission(cls, instance: ApplicationSection, user: AnyUser, inpu .distinct() ) return user.permissions.can_manage_applications_for_units(units) + + +class ApplicationSectionReservationCancellationPermission(BasePermission): + @classmethod + def has_update_permission(cls, instance: ApplicationSection, user: AnyUser, input_data: dict[str, Any]) -> bool: + return user == instance.application.user diff --git a/tilavarauspalvelu/api/graphql/types/application_section/serializers.py b/tilavarauspalvelu/api/graphql/types/application_section/serializers.py index 55003239e..f67a2eefb 100644 --- a/tilavarauspalvelu/api/graphql/types/application_section/serializers.py +++ b/tilavarauspalvelu/api/graphql/types/application_section/serializers.py @@ -1,8 +1,9 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict from django.db import models from graphene_django_extensions import NestingModelSerializer from graphene_django_extensions.serializers import NotProvided +from rest_framework import serializers from rest_framework.exceptions import ValidationError from tilavarauspalvelu.api.graphql.extensions import error_codes @@ -10,7 +11,17 @@ ReservationUnitOptionApplicantSerializer, ) from tilavarauspalvelu.api.graphql.types.suitable_time_range.serializers import SuitableTimeRangeSerializer -from tilavarauspalvelu.models import AllocatedTimeSlot, Application, ApplicationRound, ApplicationSection +from tilavarauspalvelu.enums import ApplicationRoundStatusChoice, ReservationStateChoice, ReservationTypeChoice +from tilavarauspalvelu.models import ( + AllocatedTimeSlot, + Application, + ApplicationRound, + ApplicationSection, + Reservation, + ReservationCancelReason, +) +from utils.date_utils import local_datetime +from utils.db import NowTT from utils.utils import comma_sep_str if TYPE_CHECKING: @@ -195,3 +206,94 @@ class Meta: def save(self, **kwargs: Any) -> ApplicationSection: self.instance.reservation_unit_options.all().update(rejected=False) return self.instance + + +class CancellationOutput(TypedDict): + expected_cancellations: int + actual_cancellations: int + + +class ApplicationSectionReservationCancellationInputSerializer(NestingModelSerializer): + instance: ApplicationSection + + cancel_reason = serializers.IntegerField(required=True) + cancel_details = serializers.CharField(required=False) + + class Meta: + model = ApplicationSection + fields = [ + "pk", + "cancel_reason", + "cancel_details", + ] + extra_kwargs = { + "cancel_reason": {"required": True}, + "cancel_details": {"required": False}, + } + + @staticmethod + def validate_cancel_reason(value: int) -> int: + if ReservationCancelReason.objects.filter(pk=value).exists(): + return value + msg = f"Cancel reason with pk {value} does not exist." + raise ValidationError(msg, code=error_codes.CANCEL_REASON_DOES_NOT_EXIST) + + def validate(self, data: dict[str, Any]) -> dict[str, Any]: + if self.instance.application.application_round.status != ApplicationRoundStatusChoice.RESULTS_SENT: + msg = "Application sections application round is not in 'RESULTS_SENT' state." + raise ValidationError(msg, code=error_codes.APPLICATION_ROUND_NOT_IN_RESULTS_SENT_STATE) + + return data + + def save(self, **kwargs: Any) -> CancellationOutput: + future_reservations = Reservation.objects.filter( + user=self.instance.application.user, + begin__gt=local_datetime(), + recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=self.instance, + ) + + cancellable_reservations = ( + future_reservations.filter( + type=ReservationTypeChoice.SEASONAL, + state=ReservationStateChoice.CONFIRMED, + price=0, + reservation_units__cancellation_rule__isnull=False, + ) + .alias( + cancellation_time=models.F("reservation_units__cancellation_rule__can_be_cancelled_time_before"), + cancellation_cutoff=NowTT() + models.F("cancellation_time"), + ) + .filter( + begin__gt=models.F("cancellation_cutoff"), + ) + .distinct() + ) + + data = CancellationOutput( + expected_cancellations=future_reservations.count(), + actual_cancellations=cancellable_reservations.count(), + ) + + cancellable_reservations.update( + state=ReservationStateChoice.CANCELLED, + cancel_reason=self.validated_data["cancel_reason"], + cancel_details=self.validated_data.get("cancel_details", ""), + ) + + return data + + +class ApplicationSectionReservationCancellationOutputSerializer(NestingModelSerializer): + future = serializers.IntegerField(required=True) + cancelled = serializers.IntegerField(required=True) + + class Meta: + model = ApplicationSection + fields = [ + "future", + "cancelled", + ] + extra_kwargs = { + "future": {"required": True}, + "cancelled": {"required": True}, + }