diff --git a/tests/factories/recurring_reservation.py b/tests/factories/recurring_reservation.py index 9228b2f98..6b713afec 100644 --- a/tests/factories/recurring_reservation.py +++ b/tests/factories/recurring_reservation.py @@ -1,10 +1,10 @@ import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from factory import LazyAttribute, fuzzy from tilavarauspalvelu.enums import ReservationStartInterval, ReservationStateChoice, WeekdayChoice -from tilavarauspalvelu.models import RecurringReservation +from tilavarauspalvelu.models import RecurringReservation, Reservation from utils.date_utils import DEFAULT_TIMEZONE, get_periods_between, local_datetime from ._base import ( @@ -15,6 +15,9 @@ ReverseForeignKeyFactory, ) +if TYPE_CHECKING: + from django.db import models + __all__ = [ "RecurringReservationFactory", ] @@ -70,6 +73,8 @@ def create_with_matching_reservations(cls, **kwargs: Any) -> RecurringReservatio if not weekdays: weekdays = [series.begin_date.weekday()] + reservations: list[Reservation] = [] + for weekday in weekdays: delta: int = weekday - series.begin_date.weekday() if delta < 0: @@ -86,12 +91,26 @@ def create_with_matching_reservations(cls, **kwargs: Any) -> RecurringReservatio tzinfo=DEFAULT_TIMEZONE, ) for begin, end in periods: - ReservationFactory.create( + reservation = ReservationFactory.build( recurring_reservation=series, - reservation_units=[series.reservation_unit], + user=series.user, begin=begin, end=end, **sub_kwargs, ) + reservations.append(reservation) + + Reservation.objects.bulk_create(reservations) + + # Add reservation units. + ReservationReservationUnit: type[models.Model] = Reservation.reservation_units.through # noqa: N806 + reservation_reservation_units: list[ReservationReservationUnit] = [ + ReservationReservationUnit( + reservation=reservation, + reservationunit=series.reservation_unit, + ) + for reservation in reservations + ] + ReservationReservationUnit.objects.bulk_create(reservation_reservation_units) return series diff --git a/tests/test_graphql_api/test_recurring_reservation/helpers.py b/tests/test_graphql_api/test_recurring_reservation/helpers.py index 28855bccf..4ff8ee9a1 100644 --- a/tests/test_graphql_api/test_recurring_reservation/helpers.py +++ b/tests/test_graphql_api/test_recurring_reservation/helpers.py @@ -20,6 +20,7 @@ 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") 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_deny_series.py b/tests/test_graphql_api/test_recurring_reservation/test_deny_series.py new file mode 100644 index 000000000..0fd92bf00 --- /dev/null +++ b/tests/test_graphql_api/test_recurring_reservation/test_deny_series.py @@ -0,0 +1,108 @@ +import pytest +from freezegun import freeze_time + +from tests.factories import ReservationDenyReasonFactory +from tilavarauspalvelu.enums import ReservationStateChoice +from utils.date_utils import local_datetime + +from .helpers import DENY_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__deny_series(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + "handlingDetails": "Handling details", + } + + graphql.login_with_superuser() + response = graphql(DENY_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"denied": 5, "future": 5} + assert reservation_series.reservations.count() == 9 + + future_reservations = reservation_series.reservations.filter(begin__gt=local_datetime()) + past_reservations = reservation_series.reservations.filter(begin__lte=local_datetime()) + + assert all(reservation.state == ReservationStateChoice.DENIED for reservation in future_reservations) + assert all(reservation.deny_reason == reason for reservation in future_reservations) + assert all(reservation.handling_details == "Handling details" for reservation in future_reservations) + assert all(reservation.handled_at == local_datetime() for reservation in future_reservations) + + assert all(reservation.state != ReservationStateChoice.DENIED for reservation in past_reservations) + assert all(reservation.deny_reason != reason for reservation in past_reservations) + assert all(reservation.handling_details != "Handling details" for reservation in past_reservations) + assert all(reservation.handled_at is None for reservation in past_reservations) + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__deny_series__dont_need_handling_details(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + graphql.login_with_superuser() + response = graphql(DENY_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + assert response.first_query_object == {"denied": 5, "future": 5} + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__deny_series__reason_missing(graphql): + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": 1, + "handlingDetails": "Handling details", + } + + graphql.login_with_superuser() + response = graphql(DENY_SERIES_MUTATION, input_data=data) + + assert response.error_message() == "Mutation was unsuccessful." + assert response.field_error_messages("denyReason") == ["Deny reason with pk 1 does not exist."] + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__deny_series__only_deny_certain_states(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + last_reservation = reservation_series.reservations.order_by("begin").last() + last_reservation.state = ReservationStateChoice.WAITING_FOR_PAYMENT + last_reservation.save() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + "handlingDetails": "Handling details", + } + + graphql.login_with_superuser() + response = graphql(DENY_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False, response.errors + + # Last reservation is denied since it's waiting for payment + assert response.first_query_object == {"denied": 4, "future": 5} diff --git a/tests/test_graphql_api/test_recurring_reservation/test_deny_series_permissions.py b/tests/test_graphql_api/test_recurring_reservation/test_deny_series_permissions.py new file mode 100644 index 000000000..8c6bcd8c5 --- /dev/null +++ b/tests/test_graphql_api/test_recurring_reservation/test_deny_series_permissions.py @@ -0,0 +1,131 @@ +import pytest +from freezegun import freeze_time + +from tests.factories import ReservationDenyReasonFactory, UnitRoleFactory, UserFactory +from tilavarauspalvelu.enums import UserRoleChoice +from utils.date_utils import local_datetime + +from .helpers import DENY_SERIES_MUTATION, create_reservation_series + +pytestmark = [ + pytest.mark.django_db, +] + + +@freeze_time(local_datetime(year=2024, month=1, day=1)) +def test_recurring_reservations__deny_series__regular_user(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + graphql.login_with_regular_user() + + response = graphql(DENY_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__deny_series__general_admin(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + graphql.login_user_with_role(role=UserRoleChoice.ADMIN) + + response = graphql(DENY_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__deny_series__unit_admin(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + unit = reservation_series.reservation_unit.unit + user = UserFactory.create_with_unit_role(role=UserRoleChoice.ADMIN, units=[unit]) + graphql.force_login(user) + + response = graphql(DENY_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__deny_series__unit_handler(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + unit = reservation_series.reservation_unit.unit + user = UserFactory.create_with_unit_role(role=UserRoleChoice.HANDLER, units=[unit]) + graphql.force_login(user) + + response = graphql(DENY_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__deny_series__unit_reserver__own_reservation(graphql): + reason = ReservationDenyReasonFactory.create() + + user = UserFactory.create() + + reservation_series = create_reservation_series(user=user) + + unit = reservation_series.reservation_unit.unit + UnitRoleFactory.create(user=user, role=UserRoleChoice.RESERVER, units=[unit]) + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + graphql.force_login(user) + + response = graphql(DENY_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__deny_series__unit_reserver__other_user_reservation(graphql): + reason = ReservationDenyReasonFactory.create() + + reservation_series = create_reservation_series() + + data = { + "pk": reservation_series.pk, + "denyReason": reason.pk, + } + + unit = reservation_series.reservation_unit.unit + user = UserFactory.create_with_unit_role(role=UserRoleChoice.RESERVER, units=[unit]) + graphql.force_login(user) + + response = graphql(DENY_SERIES_MUTATION, input_data=data) + + assert response.error_message() == "No permission to update." diff --git a/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series.py b/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series.py index e1a0396e2..6cf52f515 100644 --- a/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series.py +++ b/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series.py @@ -6,6 +6,7 @@ from tests.factories import RecurringReservationFactory, ReservationFactory from tilavarauspalvelu.enums import ReservationStateChoice, WeekdayChoice from tilavarauspalvelu.models import AffectingTimeSpan, Reservation, ReservationStatistic, ReservationUnitHierarchy +from tilavarauspalvelu.tasks import create_or_update_reservation_statistics from utils.date_utils import DEFAULT_TIMEZONE, combine, local_date, local_datetime, local_time from .helpers import RESCHEDULE_SERIES_MUTATION, create_reservation_series, get_minimal_reschedule_data @@ -747,6 +748,8 @@ def test_recurring_reservations__reschedule_series__create_statistics(graphql, s recurring_reservation = create_reservation_series() + create_or_update_reservation_statistics(recurring_reservation.reservations.values_list("pk", flat=True)) + # We have 9 reservations, so there should be 9 reservation statistics. assert ReservationStatistic.objects.count() == 9 @@ -773,6 +776,8 @@ def test_recurring_reservations__reschedule_series__create_statistics__partial(g recurring_reservation = create_reservation_series() + create_or_update_reservation_statistics(recurring_reservation.reservations.values_list("pk", flat=True)) + # We have 9 reservations, so there should be 9 reservation statistics. assert ReservationStatistic.objects.count() == 9 diff --git a/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series_permissions.py b/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series_permissions.py index a7b4f6291..0837ce640 100644 --- a/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series_permissions.py +++ b/tests/test_graphql_api/test_recurring_reservation/test_reschedule_series_permissions.py @@ -1,7 +1,7 @@ import pytest from freezegun import freeze_time -from tests.factories import UserFactory +from tests.factories import GeneralRoleFactory, UnitRoleFactory, UserFactory from tilavarauspalvelu.enums import UserRoleChoice from utils.date_utils import local_datetime @@ -19,7 +19,7 @@ (UserRoleChoice.ADMIN, True), (UserRoleChoice.HANDLER, True), (UserRoleChoice.VIEWER, False), - (UserRoleChoice.RESERVER, True), + (UserRoleChoice.RESERVER, False), (UserRoleChoice.NOTIFICATION_MANAGER, False), ], ) @@ -34,13 +34,26 @@ def test_recurring_reservations__reschedule_series__general_role(graphql, role, assert response.has_errors is not has_permission +@freeze_time(local_datetime(year=2023, month=12, day=1)) +def test_recurring_reservations__reschedule_series__general_reserver__own_reservation(graphql): + user = UserFactory.create() + series = create_reservation_series(user=user) + GeneralRoleFactory.create(user=user, role=UserRoleChoice.RESERVER) + + graphql.force_login(user) + data = get_minimal_reschedule_data(series) + response = graphql(RESCHEDULE_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False + + @pytest.mark.parametrize( ("role", "has_permission"), [ (UserRoleChoice.ADMIN, True), (UserRoleChoice.HANDLER, True), (UserRoleChoice.VIEWER, False), - (UserRoleChoice.RESERVER, True), + (UserRoleChoice.RESERVER, False), (UserRoleChoice.NOTIFICATION_MANAGER, False), ], ) @@ -54,3 +67,16 @@ def test_recurring_reservations__reschedule_series__unit_role(graphql, role, has response = graphql(RESCHEDULE_SERIES_MUTATION, input_data=data) assert response.has_errors is not has_permission + + +@freeze_time(local_datetime(year=2023, month=12, day=1)) +def test_recurring_reservations__reschedule_series__unit_reserver__own_reservation(graphql): + user = UserFactory.create() + series = create_reservation_series(user=user) + UnitRoleFactory.create(user=user, role=UserRoleChoice.RESERVER, units=[series.reservation_unit.unit]) + + graphql.force_login(user) + data = get_minimal_reschedule_data(series) + response = graphql(RESCHEDULE_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False diff --git a/tests/test_graphql_api/test_recurring_reservation/test_update_series_permissions.py b/tests/test_graphql_api/test_recurring_reservation/test_update_series_permissions.py index c5ada1120..7714f1252 100644 --- a/tests/test_graphql_api/test_recurring_reservation/test_update_series_permissions.py +++ b/tests/test_graphql_api/test_recurring_reservation/test_update_series_permissions.py @@ -1,6 +1,6 @@ import pytest -from tests.factories import RecurringReservationFactory, UserFactory +from tests.factories import GeneralRoleFactory, RecurringReservationFactory, UnitRoleFactory, UserFactory from tilavarauspalvelu.enums import UserRoleChoice from .helpers import UPDATE_SERIES_MUTATION @@ -17,7 +17,7 @@ (UserRoleChoice.ADMIN, True), (UserRoleChoice.HANDLER, True), (UserRoleChoice.VIEWER, False), - (UserRoleChoice.RESERVER, True), + (UserRoleChoice.RESERVER, False), (UserRoleChoice.NOTIFICATION_MANAGER, False), ], ) @@ -31,13 +31,25 @@ def test_recurring_reservations__update_series__general_admin(graphql, role, has assert response.has_errors is not has_permission +def test_recurring_reservations__update_series__general_reserver__own_reservation(graphql): + user = UserFactory.create() + series = RecurringReservationFactory.create(user=user) + GeneralRoleFactory.create(user=user, role=UserRoleChoice.RESERVER) + + data = {"pk": series.id, "name": "New name"} + graphql.force_login(user=user) + response = graphql(UPDATE_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False + + @pytest.mark.parametrize( ("role", "has_permission"), [ (UserRoleChoice.ADMIN, True), (UserRoleChoice.HANDLER, True), (UserRoleChoice.VIEWER, False), - (UserRoleChoice.RESERVER, True), + (UserRoleChoice.RESERVER, False), (UserRoleChoice.NOTIFICATION_MANAGER, False), ], ) @@ -50,3 +62,15 @@ def test_recurring_reservations__update_series__unit_admin(graphql, role, has_pe response = graphql(UPDATE_SERIES_MUTATION, input_data=data) assert response.has_errors is not has_permission + + +def test_recurring_reservations__update_series__unit_reserver__own_reservation(graphql): + user = UserFactory.create() + series = RecurringReservationFactory.create(user=user) + UnitRoleFactory.create(user=user, role=UserRoleChoice.RESERVER, units=[series.reservation_unit.unit]) + + data = {"pk": series.id, "name": "New name"} + graphql.force_login(user) + response = graphql(UPDATE_SERIES_MUTATION, input_data=data) + + assert response.has_errors is False diff --git a/tilavarauspalvelu/api/graphql/extensions/error_codes.py b/tilavarauspalvelu/api/graphql/extensions/error_codes.py index 5730ce001..b76d91c5b 100644 --- a/tilavarauspalvelu/api/graphql/extensions/error_codes.py +++ b/tilavarauspalvelu/api/graphql/extensions/error_codes.py @@ -67,3 +67,5 @@ 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" + +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 f4630ab11..547d5b3be 100644 --- a/tilavarauspalvelu/api/graphql/mutations.py +++ b/tilavarauspalvelu/api/graphql/mutations.py @@ -46,6 +46,7 @@ from .types.purpose.mutations import PurposeCreateMutation, PurposeUpdateMutation from .types.recurring_reservation.mutations import ( ReservationSeriesCreateMutation, + ReservationSeriesDenyMutation, ReservationSeriesRescheduleMutation, ReservationSeriesUpdateMutation, ) @@ -111,6 +112,7 @@ "ReservationRefundMutation", "ReservationRequiresHandlingMutation", "ReservationSeriesCreateMutation", + "ReservationSeriesDenyMutation", "ReservationSeriesRescheduleMutation", "ReservationSeriesUpdateMutation", "ReservationStaffAdjustTimeMutation", diff --git a/tilavarauspalvelu/api/graphql/schema.py b/tilavarauspalvelu/api/graphql/schema.py index b6630122a..6d8f7732f 100644 --- a/tilavarauspalvelu/api/graphql/schema.py +++ b/tilavarauspalvelu/api/graphql/schema.py @@ -52,6 +52,7 @@ ReservationRefundMutation, ReservationRequiresHandlingMutation, ReservationSeriesCreateMutation, + ReservationSeriesDenyMutation, ReservationSeriesRescheduleMutation, ReservationSeriesUpdateMutation, ReservationStaffAdjustTimeMutation, @@ -350,6 +351,7 @@ class Mutation(graphene.ObjectType): create_reservation_series = ReservationSeriesCreateMutation.Field() update_reservation_series = ReservationSeriesUpdateMutation.Field() reschedule_reservation_series = ReservationSeriesRescheduleMutation.Field() + deny_reservation_series = ReservationSeriesDenyMutation.Field() refresh_order = RefreshOrderMutation.Field() # # User diff --git a/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py b/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py index 551f91dc2..02d2001d7 100644 --- a/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py +++ b/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py @@ -1,8 +1,16 @@ +from typing import Any + from graphene_django_extensions import CreateMutation, UpdateMutation +from tilavarauspalvelu.enums import ReservationStateChoice +from tilavarauspalvelu.models import RecurringReservation +from utils.date_utils import local_datetime + from .permissions import RecurringReservationPermission from .serializers import ( ReservationSeriesCreateSerializer, + ReservationSeriesDenyInputSerializer, + ReservationSeriesDenyOutputSerializer, ReservationSeriesRescheduleSerializer, ReservationSeriesUpdateSerializer, ) @@ -30,3 +38,18 @@ class ReservationSeriesRescheduleMutation(UpdateMutation): class Meta: serializer_class = ReservationSeriesRescheduleSerializer permission_classes = [RecurringReservationPermission] + + +class ReservationSeriesDenyMutation(UpdateMutation): + class Meta: + serializer_class = ReservationSeriesDenyInputSerializer + output_serializer_class = ReservationSeriesDenyOutputSerializer + permission_classes = [RecurringReservationPermission] + + @classmethod + def get_serializer_output(cls, instance: RecurringReservation) -> dict[str, Any]: + future_reservations = instance.reservations.filter(begin__gt=local_datetime()) + return { + "denied": future_reservations.filter(state=ReservationStateChoice.DENIED).count(), + "future": future_reservations.count(), + } diff --git a/tilavarauspalvelu/api/graphql/types/recurring_reservation/permissions.py b/tilavarauspalvelu/api/graphql/types/recurring_reservation/permissions.py index 6f266db3c..23ae5db50 100644 --- a/tilavarauspalvelu/api/graphql/types/recurring_reservation/permissions.py +++ b/tilavarauspalvelu/api/graphql/types/recurring_reservation/permissions.py @@ -31,11 +31,12 @@ def has_mutation_permission(cls, user: AnyUser, input_data: dict[str, Any]) -> b @classmethod def has_create_permission(cls, user: AnyUser, input_data: dict[str, Any]) -> bool: reservation_unit = cls._get_reservation_unit(input_data) - return user.permissions.can_create_staff_reservation(reservation_unit) + return user.permissions.can_create_staff_reservation(reservation_unit, is_reservee=True) @classmethod def has_update_permission(cls, instance: RecurringReservation, user: AnyUser, input_data: dict[str, Any]) -> bool: - return user.permissions.can_create_staff_reservation(instance.reservation_unit) + is_reservee = instance.user == user + return user.permissions.can_create_staff_reservation(instance.reservation_unit, is_reservee=is_reservee) @classmethod def _get_reservation_unit(cls, input_data: dict[str, Any]) -> ReservationUnit: diff --git a/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py b/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py index 2bfd753de..c44dfffbd 100644 --- a/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py +++ b/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers.py @@ -18,7 +18,13 @@ ReservationTypeStaffChoice, WeekdayChoice, ) -from tilavarauspalvelu.models import RecurringReservation, Reservation, ReservationStatistic, ReservationUnit +from tilavarauspalvelu.models import ( + RecurringReservation, + Reservation, + ReservationDenyReason, + ReservationStatistic, + ReservationUnit, +) from tilavarauspalvelu.models.recurring_reservation.actions import ReservationDetails from tilavarauspalvelu.tasks import create_or_update_reservation_statistics, update_affecting_time_spans_task from tilavarauspalvelu.utils.opening_hours.reservable_time_span_client import ReservableTimeSpanClient @@ -611,3 +617,67 @@ def validate_series_time_slots( if not is_valid_start_interval: msg = f"Reservation start time does not match the allowed interval of {interval_minutes} minutes." raise ValidationError(msg, code=error_codes.RESERVATION_TIME_DOES_NOT_MATCH_ALLOWED_INTERVAL) + + +class ReservationSeriesDenyInputSerializer(NestingModelSerializer): + instance: RecurringReservation + + deny_reason = serializers.IntegerField(required=True) + handling_details = serializers.CharField(required=False) + + class Meta: + model = RecurringReservation + fields = [ + "pk", + "deny_reason", + "handling_details", + ] + extra_kwargs = { + "deny_reason": {"required": True}, + "handling_details": {"required": False}, + } + + @staticmethod + def validate_deny_reason(value: int) -> int: + if ReservationDenyReason.objects.filter(pk=value).exists(): + return value + msg = f"Deny reason with pk {value} does not exist." + raise ValidationError(msg, code=error_codes.DENY_REASON_DOES_NOT_EXIST) + + def save(self, **kwargs: Any) -> RecurringReservation: + now = local_datetime() + + reservations = self.instance.reservations.filter( + begin__gt=now, + state__in=ReservationStateChoice.states_that_can_change_to_deny, + ) + + reservations.update( + state=ReservationStateChoice.DENIED, + deny_reason=self.validated_data["deny_reason"], + handling_details=self.validated_data.get("handling_details", ""), + handled_at=now, + ) + + # Must refresh the materialized view since reservations state changed to 'DENIED' + if settings.UPDATE_AFFECTING_TIME_SPANS: + update_affecting_time_spans_task.delay() + + if settings.SAVE_RESERVATION_STATISTICS: + create_or_update_reservation_statistics.delay( + reservation_pks=[reservation.pk for reservation in reservations], + ) + + return self.instance + + +class ReservationSeriesDenyOutputSerializer(NestingModelSerializer): + denied = serializers.IntegerField(required=True) + future = serializers.IntegerField(required=True) + + class Meta: + model = RecurringReservation + fields = [ + "denied", + "future", + ] diff --git a/tilavarauspalvelu/api/graphql/types/reservation/permissions.py b/tilavarauspalvelu/api/graphql/types/reservation/permissions.py index 215a70e88..15f48b927 100644 --- a/tilavarauspalvelu/api/graphql/types/reservation/permissions.py +++ b/tilavarauspalvelu/api/graphql/types/reservation/permissions.py @@ -82,7 +82,7 @@ class ReservationStaffCreatePermission(BasePermission): @classmethod def has_create_permission(cls, user: AnyUser, input_data: dict[str, Any]) -> bool: reservation_unit = cls._get_reservation_unit(input_data) - return user.permissions.can_create_staff_reservation(reservation_unit) + return user.permissions.can_create_staff_reservation(reservation_unit, is_reservee=True) @classmethod def _get_reservation_unit(cls, input_data: dict[str, Any]) -> ReservationUnit: diff --git a/tilavarauspalvelu/utils/permission_resolver.py b/tilavarauspalvelu/utils/permission_resolver.py index 9a5ea5e75..4f558fc38 100644 --- a/tilavarauspalvelu/utils/permission_resolver.py +++ b/tilavarauspalvelu/utils/permission_resolver.py @@ -52,16 +52,34 @@ def has_any_role(self) -> bool: or bool(self.user.active_unit_group_roles) ) - def has_general_role(self, *, role_choices: Container[UserRoleChoice] | None = None) -> bool: + def has_general_role( + self, + *, + role_choices: Container[UserRoleChoice] | None = None, + permit_reserver: bool = True, + ) -> bool: """ Check if the user has any of the given roles in their general roles. If no choices are given, check if the user has any general role. + + :param role_choices: The roles to check for. If not given, check if the user has any general role. + :param permit_reserver: If set to False, don't count the `RESERVER` role if the user has it. + Reservers are only supposed to be able to modify their own reservations, + so this can be set to False if checking permissions for other user's reservations. """ if self.is_user_anonymous_or_inactive(): return False if role_choices is None: # Has any general role - return bool(self.user.active_general_roles) - return any(role in role_choices for role in self.user.active_general_roles) + return any( + role # + for role in self.user.active_general_roles + if permit_reserver or role != UserRoleChoice.RESERVER + ) + return any( + role in role_choices # + for role in self.user.active_general_roles + if permit_reserver or role != UserRoleChoice.RESERVER + ) def has_role_for_units_or_their_unit_groups( self, @@ -69,6 +87,7 @@ def has_role_for_units_or_their_unit_groups( units: Iterable[Unit] | None = None, role_choices: Container[UserRoleChoice] | None = None, require_all: bool = False, + permit_reserver: bool = True, ) -> bool: """ Check if the user has at least one of the given roles in the given units or their unit groups. @@ -79,6 +98,9 @@ def has_role_for_units_or_their_unit_groups( :param units: Units to check for the role. :param role_choices: Roles to check for. :param require_all: If True, require roles in all the given units or their unit groups instead of any. + :param permit_reserver: If set to False, don't count the `RESERVER` role as a role for the given units. + Reservers are only supposed to be able to modify their own reservations, + so this can be set to False if checking permissions for other user's reservations. """ if self.is_user_anonymous_or_inactive(): return False @@ -104,7 +126,11 @@ def has_role_for_units_or_their_unit_groups( has_role = False for unit in units: roles = self.user.active_unit_roles.get(unit.pk, []) - has_role = any(role in role_choices for role in roles) + has_role = any( + role in role_choices # + for role in roles + if permit_reserver or role != UserRoleChoice.RESERVER + ) # No role though units -> check through unit groups if not has_role: @@ -112,6 +138,7 @@ def has_role_for_units_or_their_unit_groups( role in role_choices for unit_group in unit.unit_groups.all() for role in self.user.active_unit_group_roles.get(unit_group.pk, []) + if permit_reserver or role != UserRoleChoice.RESERVER ) # If we require roles for all units, we need to keep checking until all units have been checked. @@ -199,20 +226,21 @@ def unit_group_ids_where_has_role(self, *, role_choices: Container[UserRoleChoic # Permission checks - def can_create_staff_reservation(self, reservation_unit: ReservationUnit) -> bool: + def can_create_staff_reservation(self, reservation_unit: ReservationUnit, *, is_reservee: bool = False) -> bool: if self.is_user_anonymous_or_inactive(): return False if self.user.is_superuser: return True role_choices = UserRoleChoice.can_create_staff_reservations() - if self.has_general_role(role_choices=role_choices): + if self.has_general_role(role_choices=role_choices, permit_reserver=is_reservee): return True return self.has_role_for_units_or_their_unit_groups( units=[reservation_unit.unit], role_choices=role_choices, require_all=True, + permit_reserver=is_reservee, ) def can_manage_application(