From 8cdab052ad8e0abcc13ee7de19ca436d8b2f6a83 Mon Sep 17 00:00:00 2001 From: Matti Lamppu Date: Wed, 29 Jan 2025 11:14:50 +0200 Subject: [PATCH] Add tasks for activating access codes on failure --- ...est_create_missing_pindora_reservations.py | 265 ++++++++++++++++++ .../test_update_reservation_is_activate.py | 241 ++++++++++++++++ tilavarauspalvelu/tasks.py | 69 +++++ 3 files changed, 575 insertions(+) create mode 100644 tests/test_tasks/test_create_missing_pindora_reservations.py create mode 100644 tests/test_tasks/test_update_reservation_is_activate.py diff --git a/tests/test_tasks/test_create_missing_pindora_reservations.py b/tests/test_tasks/test_create_missing_pindora_reservations.py new file mode 100644 index 000000000..5c45b3330 --- /dev/null +++ b/tests/test_tasks/test_create_missing_pindora_reservations.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import datetime + +import pytest +from freezegun import freeze_time + +from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice +from tilavarauspalvelu.integrations.keyless_entry import PindoraClient +from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError +from tilavarauspalvelu.tasks import create_missing_pindora_reservations +from utils.date_utils import DEFAULT_TIMEZONE, local_datetime + +from tests.factories import ReservationFactory +from tests.helpers import patch_method + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__create_missing(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + data = { + "access_code_generated_at": datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE), + "access_code_is_active": True, + } + with patch_method(PindoraClient.create_reservation, return_value=data) as patch: + create_missing_pindora_reservations() + + assert patch.called is True + assert patch.call_args.kwargs["is_active"] is True + + reservation.refresh_from_db() + assert reservation.access_code_generated_at == datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE) + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__blocked(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.BLOCKED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + data = { + "access_code_generated_at": datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE), + "access_code_is_active": False, + } + with patch_method(PindoraClient.create_reservation, return_value=data) as patch: + create_missing_pindora_reservations() + + assert patch.called is True + assert patch.call_args.kwargs["is_active"] is False + + reservation.refresh_from_db() + assert reservation.access_code_generated_at == datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE) + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__in_the_past(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now - datetime.timedelta(days=1), + end=now - datetime.timedelta(days=1, hours=1), + ) + + with patch_method(PindoraClient.create_reservation) as patch: + create_missing_pindora_reservations() + + assert patch.called is False + + reservation.refresh_from_db() + assert reservation.access_code_generated_at is None + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__ongoing(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now - datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + data = { + "access_code_generated_at": datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE), + "access_code_is_active": True, + } + with patch_method(PindoraClient.create_reservation, return_value=data) as patch: + create_missing_pindora_reservations() + + assert patch.called is True + assert patch.call_args.kwargs["is_active"] is True + + reservation.refresh_from_db() + assert reservation.access_code_generated_at == datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE) + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__not_confirmed(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.WAITING_FOR_PAYMENT, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + with patch_method(PindoraClient.create_reservation) as patch: + create_missing_pindora_reservations() + + assert patch.called is False + + reservation.refresh_from_db() + assert reservation.access_code_generated_at is None + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__not_access_code(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.UNRESTRICTED, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + with patch_method(PindoraClient.create_reservation) as patch: + create_missing_pindora_reservations() + + assert patch.called is False + + reservation.refresh_from_db() + assert reservation.access_code_generated_at is None + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__already_generated(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=now, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + with patch_method(PindoraClient.create_reservation) as patch: + create_missing_pindora_reservations() + + assert patch.called is False + + reservation.refresh_from_db() + assert reservation.access_code_generated_at == now + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__multiple(): + now = local_datetime() + + ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + data = { + "access_code_generated_at": datetime.datetime(2023, 1, 1, tzinfo=DEFAULT_TIMEZONE), + "access_code_is_active": False, + } + with patch_method(PindoraClient.create_reservation, return_value=data) as patch: + create_missing_pindora_reservations() + + assert patch.call_count == 2 + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_create_missing_pindora_reservations__pindora_error(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + with patch_method(PindoraClient.create_reservation, side_effect=PindoraAPIError()) as patch: + create_missing_pindora_reservations() + + assert patch.called is True + + reservation.refresh_from_db() + assert reservation.access_code_generated_at is None + assert reservation.access_code_is_active is False diff --git a/tests/test_tasks/test_update_reservation_is_activate.py b/tests/test_tasks/test_update_reservation_is_activate.py new file mode 100644 index 000000000..826a5d160 --- /dev/null +++ b/tests/test_tasks/test_update_reservation_is_activate.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import datetime + +import pytest +from freezegun import freeze_time + +from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice +from tilavarauspalvelu.integrations.keyless_entry import PindoraClient +from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraAPIError, PindoraNotFoundError +from tilavarauspalvelu.tasks import update_reservation_is_activate +from utils.date_utils import local_datetime + +from tests.factories import ReservationFactory +from tests.helpers import patch_method + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__activate(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is True + assert deactivate.called is False + + reservation.refresh_from_db() + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__deactivate(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.BLOCKED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=True, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is False + assert deactivate.called is True + + reservation.refresh_from_db() + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__already_active(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=True, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is False + assert deactivate.called is False + + reservation.refresh_from_db() + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__already_not_active(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.BLOCKED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is False + assert deactivate.called is False + + reservation.refresh_from_db() + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__activate__pindora_error(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code, side_effect=PindoraAPIError()) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code, side_effect=PindoraAPIError()) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is True + assert deactivate.called is False + + reservation.refresh_from_db() + assert reservation.access_code_is_active is False + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__activate__pindora_error__404(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.NORMAL, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=False, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + error = PindoraNotFoundError("error") + active_patch = patch_method(PindoraClient.activate_reservation_access_code, side_effect=error) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code, side_effect=error) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is True + assert deactivate.called is False + + reservation.refresh_from_db() + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__deactivate__pindora_error(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.BLOCKED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=True, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + active_patch = patch_method(PindoraClient.activate_reservation_access_code, side_effect=PindoraAPIError()) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code, side_effect=PindoraAPIError()) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is False + assert deactivate.called is True + + reservation.refresh_from_db() + assert reservation.access_code_is_active is True + + +@pytest.mark.django_db +@freeze_time("2023-01-01") +def test_update_reservation_is_activate__deactivate__pindora_error__404(): + now = local_datetime() + + reservation = ReservationFactory.create( + state=ReservationStateChoice.CONFIRMED, + type=ReservationTypeChoice.BLOCKED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=local_datetime(), + access_code_is_active=True, + begin=now + datetime.timedelta(days=1), + end=now + datetime.timedelta(days=1, hours=1), + ) + + error = PindoraNotFoundError("error") + active_patch = patch_method(PindoraClient.activate_reservation_access_code, side_effect=error) + deactivate_patch = patch_method(PindoraClient.deactivate_reservation_access_code, side_effect=error) + + with active_patch as activate, deactivate_patch as deactivate: + update_reservation_is_activate() + + assert activate.called is False + assert deactivate.called is True + + reservation.refresh_from_db() + assert reservation.access_code_is_active is False diff --git a/tilavarauspalvelu/tasks.py b/tilavarauspalvelu/tasks.py index ca111950d..add89efdb 100644 --- a/tilavarauspalvelu/tasks.py +++ b/tilavarauspalvelu/tasks.py @@ -18,6 +18,7 @@ from config.celery import app from tilavarauspalvelu.enums import ( + AccessType, ApplicantTypeChoice, ApplicationRoundStatusChoice, ApplicationStatusChoice, @@ -711,6 +712,74 @@ def anonymize_old_users_task() -> None: retry_backoff=True, ) def delete_pindora_reservation(reservation_uuid: str) -> None: + """ + Task that can be used to retry a Pindora reservation deletion if it fails + in the endpoint. This should only be called if the access code is know to + be inactive, since this task may also fail to delete the reservation if + Pindora is down for an extended period of time. + """ from tilavarauspalvelu.integrations.keyless_entry import PindoraClient PindoraClient.delete_reservation(reservation=uuid.UUID(reservation_uuid)) + + +@app.task(name="create_missing_pindora_reservations") +def create_missing_pindora_reservations() -> None: + """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here.""" + from tilavarauspalvelu.integrations.keyless_entry import PindoraClient + + reservations: list[Reservation] = list( + Reservation.objects.filter( + state=ReservationStateChoice.CONFIRMED, + access_type=AccessType.ACCESS_CODE, + access_code_generated_at=None, + end__gt=local_datetime(), + ) + ) + + if not reservations: + return + + for reservation in reservations: + is_active = reservation.access_code_should_be_active + + with suppress(Exception): + response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active) + reservation.access_code_generated_at = response["access_code_generated_at"] + reservation.access_code_is_active = response["access_code_is_active"] + + Reservation.objects.bulk_update(reservations, ["access_code_generated_at", "access_code_is_active"]) + + +@app.task(name="update_reservation_is_activate") +def update_reservation_is_activate() -> None: + """If a reservation's access code active state failed to update in Pindora, retry the change here.""" + from tilavarauspalvelu.integrations.keyless_entry import PindoraClient + from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError + + reservations: list[Reservation] = list( + Reservation.objects.filter( + ( + (Q(access_code_is_active=True) & L(access_code_should_be_active=False)) + | (Q(access_code_is_active=False) & L(access_code_should_be_active=True)) + ), + access_code_generated_at__isnull=False, + end__gt=local_datetime(), + ) + ) + + if not reservations: + return + + for reservation in reservations: + with suppress(Exception): + if reservation.access_code_should_be_active: + with suppress(PindoraNotFoundError): + PindoraClient.activate_reservation_access_code(reservation=reservation) + reservation.access_code_is_active = True + else: + with suppress(PindoraNotFoundError): + PindoraClient.deactivate_reservation_access_code(reservation=reservation) + reservation.access_code_is_active = False + + Reservation.objects.bulk_update(reservations, ["access_code_is_active"])