Repository: City-of-Helsinki/tilavarauspalvelu-core
Pull request #1632: Series and sections Pindora tasks
Author: login: matti-lamppu
Labels: name: feature
description: Adds a new feature
Created: 2025-03-11T11:10:38Z | Merged: 2025-03-12T14:45:46Z

## 🛠️ Changelog
[//]: # "Describe the changes in this pull request here."

- Create access codes for series and seasonal bookings in the background
- Update access code is active for series and seasonal booking in the background

## 🧪 Test plan
[//]: # "Help your fellow reviewer and write a short description of the fastest way to test your changes."

- Automated tests

## 🚧 Dependencies
[//]: # "Is this PR dependent on other changes outside this repository? Describe the changes, or add links to them."
[//]: # "e.g. This PR breaks X and is waiting for frontend changes before merging."

- None

## 🎫 Tickets
[//]: # "This pull request resolves all or part of the following ticket(s)."

- [TILA-3838](https://helsinkisolutionoffice.atlassian.net/browse/TILA-3838)
- [TILA-3833](https://helsinkisolutionoffice.atlassian.net/browse/TILA-3833)


[TILA-3838]: https://helsinkisolutionoffice.atlassian.net/browse/TILA-3838?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[TILA-3833]: https://helsinkisolutionoffice.atlassian.net/browse/TILA-3833?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/backend/tests/test_actions/test_application_round_actions.py b/backend/tests/test_actions/test_application_round_actions.py
index 97edd7005d..e9de80b349 100644
--- a/backend/tests/test_actions/test_application_round_actions.py
+++ b/backend/tests/test_actions/test_application_round_actions.py
@@ -2,10 +2,17 @@
 
 import pytest
 
-from tilavarauspalvelu.enums import ApplicationRoundStatusChoice, ReservationTypeChoice
+from tilavarauspalvelu.enums import (
+    AccessType,
+    ApplicationRoundStatusChoice,
+    ReservationStateChoice,
+    ReservationTypeChoice,
+)
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
 from tilavarauspalvelu.models import AllocatedTimeSlot, RecurringReservation, Reservation, ReservationUnitOption
 
 from tests.factories import AllocatedTimeSlotFactory, ApplicationFactory, ApplicationRoundFactory, ReservationFactory
+from tests.helpers import patch_method
 
 # Applied to all tests
 pytestmark = [
@@ -79,6 +86,35 @@ def test_reset_application_round_allocation__handled():
     assert application_round.handled_date is None
 
 
+@patch_method(PindoraService.delete_access_code)
+def test_reset_application_round_allocation__handled__call_pindora_if_has_access_codes():
+    application_round = ApplicationRoundFactory.create_in_status_handled()
+
+    allocation_1 = AllocatedTimeSlotFactory.create(
+        reservation_unit_option__application_section__application__application_round=application_round,
+    )
+    allocation_2 = AllocatedTimeSlotFactory.create(
+        reservation_unit_option__locked=True,
+        reservation_unit_option__application_section__application__application_round=application_round,
+    )
+    ReservationFactory.create(
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.SEASONAL,
+        recurring_reservation__allocated_time_slot=allocation_1,
+    )
+    ReservationFactory.create(
+        access_type=AccessType.UNRESTRICTED,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.SEASONAL,
+        recurring_reservation__allocated_time_slot=allocation_2,
+    )
+
+    application_round.actions.reset_application_round_allocation()
+
+    assert PindoraService.delete_access_code.call_count == 1
+
+
 def test_reset_application_round_allocation__results_sent():
     application_round = ApplicationRoundFactory.create_in_status_results_sent()
     application = ApplicationFactory.create_in_status_results_sent(application_round=application_round)
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_create_missing_access_codes.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_create_missing_access_codes.py
new file mode 100644
index 0000000000..6f7455373a
--- /dev/null
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_create_missing_access_codes.py
@@ -0,0 +1,579 @@
+from __future__ import annotations
+
+import uuid
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraConflictError
+from tilavarauspalvelu.integrations.keyless_entry.typing import PindoraAccessCodeModifyResponse
+from tilavarauspalvelu.typing import PindoraReservationInfoData
+from utils.date_utils import local_datetime
+
+from tests.factories import ApplicationSectionFactory, RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__reservation():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == reservation
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is True
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__reservation__as_inactive():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == reservation
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__reservation__not_confirmed():
+    ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.WAITING_FOR_PAYMENT,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__reservation__already_generated():
+    ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(),
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.get_access_code,
+    return_value=PindoraReservationInfoData(
+        access_code="1234",
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+        access_code_keypad_url="1234",
+        access_code_phone_number="1234",
+        access_code_sms_number="1234",
+        access_code_sms_message="1234",
+        access_code_begins_at=local_datetime(2024, 1, 1, 12),
+        access_code_ends_at=local_datetime(2024, 1, 1, 13),
+    ),
+)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__reservation__get_if_exists():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.get_access_code.called is True
+    assert PindoraService.get_access_code.call_args.kwargs["obj"] == reservation
+
+    reservation.refresh_from_db()
+    assert reservation.access_code_generated_at == local_datetime(2024, 1, 1)
+    assert reservation.access_code_is_active is True
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == series
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is True
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__as_inactive():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == series
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__not_confirmed():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.WAITING_FOR_PAYMENT,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__already_generated():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(),
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__reschedule_if_exists():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == series
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=False,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__reschedule_if_exists__activate():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == series
+
+    assert PindoraService.activate_access_code.called is True
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__series__reschedule_if_exists__deactivate():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == series
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == section
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is True
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__as_inactive():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.create_access_code.call_args.kwargs["obj"] == section
+    assert PindoraService.create_access_code.call_args.kwargs["is_active"] is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__not_confirmed():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.WAITING_FOR_PAYMENT,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__already_generated():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(),
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__reschedule_if_exists():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == section
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=False,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__reschedule_if_exists__activate():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == section
+
+    assert PindoraService.activate_access_code.called is True
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.create_access_code, side_effect=PindoraConflictError("conflict"))
+@patch_method(
+    PindoraService.reschedule_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_create_missing_access_codes__seasonal_booking__reschedule_if_exists__deactivate():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section,
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=None,
+    )
+
+    PindoraService.create_missing_access_codes()
+
+    assert PindoraService.create_access_code.called is True
+    assert PindoraService.reschedule_access_code.called is True
+    assert PindoraService.reschedule_access_code.call_args.kwargs["obj"] == section
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_update_access_code_is_active.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_update_access_code_is_active.py
new file mode 100644
index 0000000000..bcab773a1e
--- /dev/null
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_update_access_code_is_active.py
@@ -0,0 +1,414 @@
+from __future__ import annotations
+
+import uuid
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError
+from utils.date_utils import local_datetime
+
+from tests.factories import ApplicationSectionFactory, RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__reservation__active_when_should_be_inactive():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == reservation
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__reservation__active_when_should_be_active():
+    ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__reservation__inactive_when_should_be_active():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is True
+    assert PindoraService.activate_access_code.call_args.kwargs["obj"] == reservation
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__reservation__inactive_when_should_be_inactive():
+    ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("not found"))
+@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("not found"))
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__reservation__not_found():
+    reservation = ReservationFactory.create(
+        reservation_units__uuid=uuid.uuid4(),
+        recurring_reservation=None,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == reservation
+
+    reservation.refresh_from_db()
+    assert reservation.access_code_is_active is False
+    assert reservation.access_code_generated_at is None
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__series__active_when_should_be_inactive():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == series
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__series__active_when_should_be_active():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__series__inactive_when_should_be_active():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is True
+    assert PindoraService.activate_access_code.call_args.kwargs["obj"] == series
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__series__inactive_when_should_be_inactive():
+    series = RecurringReservationFactory.create()
+    ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("not found"))
+@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("not found"))
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__series__not_found():
+    series = RecurringReservationFactory.create()
+    reservation = ReservationFactory.create(
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == series
+
+    reservation.refresh_from_db()
+    assert reservation.access_code_is_active is False
+    assert reservation.access_code_generated_at is None
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__seasonal_booking__active_when_should_be_inactive():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == section
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__seasonal_booking__active_when_should_be_active():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__seasonal_booking__inactive_when_should_be_active():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is True
+    assert PindoraService.activate_access_code.call_args.kwargs["obj"] == section
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__seasonal_booking__inactive_when_should_be_inactive():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section
+    )
+    ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=False,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is False
+
+
+@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("not found"))
+@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("not found"))
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_update_access_code_is_active__seasonal_booking__not_found():
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        allocated_time_slot__reservation_unit_option__application_section=section
+    )
+    reservation = ReservationFactory.create(
+        user=user,
+        reservation_units=[series.reservation_unit],
+        recurring_reservation=series,
+        begin=local_datetime(2024, 1, 1, 12),
+        end=local_datetime(2024, 1, 1, 13),
+        access_type=AccessType.ACCESS_CODE,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.BLOCKED,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    PindoraService.update_access_code_is_active()
+
+    assert PindoraService.activate_access_code.called is False
+    assert PindoraService.deactivate_access_code.called is True
+    assert PindoraService.deactivate_access_code.call_args.kwargs["obj"] == section
+
+    reservation.refresh_from_db()
+    assert reservation.access_code_is_active is False
+    assert reservation.access_code_generated_at is None
diff --git a/backend/tilavarauspalvelu/api/graphql/types/application_section/serializers.py b/backend/tilavarauspalvelu/api/graphql/types/application_section/serializers.py
index c82eb3462a..26fd128702 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/application_section/serializers.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/application_section/serializers.py
@@ -253,10 +253,9 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]:
         return data
 
     def save(self, **kwargs: Any) -> CancellationOutput:
-        future_reservations = Reservation.objects.filter(
+        future_reservations = Reservation.objects.for_application_section(self.instance).filter(
             user=self.instance.application.user,
             begin__gt=local_datetime(),
-            recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=self.instance,
         )
 
         cancellable_reservations: ReservationQuerySet = (
diff --git a/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py b/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
index 6cf87ebf17..69846938bc 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/application_section/types.py
@@ -146,11 +146,7 @@ def _add_units_for_permissions(cls, queryset: ApplicationQuerySet, *args: Any) -
     @staticmethod
     def optimize_has_reservations(queryset: models.QuerySet, optimizer: QueryOptimizer) -> models.QuerySet:
         optimizer.annotations["has_reservations"] = models.Exists(
-            Reservation.objects.filter(
-                recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=(
-                    models.OuterRef("pk")
-                ),
-            )
+            queryset=Reservation.objects.for_application_section(models.OuterRef("pk"))
         )
 
         return queryset
diff --git a/backend/tilavarauspalvelu/integrations/keyless_entry/service.py b/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
index bca5d507d5..75072db663 100644
--- a/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
+++ b/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
@@ -4,9 +4,6 @@
 from contextlib import suppress
 from typing import TYPE_CHECKING, overload
 
-from django.db import models
-from lookup_property import L
-
 from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
 from tilavarauspalvelu.integrations.email.main import EmailService
 from tilavarauspalvelu.models import ApplicationSection, RecurringReservation, Reservation
@@ -16,7 +13,7 @@
     PindoraSeriesInfoData,
     PindoraValidityInfoData,
 )
-from utils.date_utils import DEFAULT_TIMEZONE, local_datetime
+from utils.date_utils import DEFAULT_TIMEZONE
 from utils.external_service.errors import ExternalServiceError
 
 from .client import PindoraClient
@@ -25,6 +22,7 @@
 
 if TYPE_CHECKING:
     import uuid
+    from collections.abc import Iterable
 
     from .typing import (
         PindoraAccessCodeValidity,
@@ -346,17 +344,16 @@ def delete_access_code(cls, obj: ApplicationSection | RecurringReservation | Res
             case ApplicationSection():
                 PindoraClient.delete_seasonal_booking(obj)
 
-                Reservation.objects.filter(
-                    recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=obj,
-                ).update(access_code_generated_at=None, access_code_is_active=False)
+                Reservation.objects.for_application_section(obj).update(
+                    access_code_generated_at=None,
+                    access_code_is_active=False,
+                )
 
             case RecurringReservation():
                 if obj.allocated_time_slot is None:
                     PindoraClient.delete_reservation_series(obj)
 
-                    Reservation.objects.filter(
-                        recurring_reservation=obj,
-                    ).update(access_code_generated_at=None, access_code_is_active=False)
+                    obj.reservations.update(access_code_generated_at=None, access_code_is_active=False)
 
                     return
 
@@ -413,37 +410,50 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
@@ -451,52 +461,120 @@ def create_missing_reservations(cls) -> None:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, reschedule and activate/deactivate instead.
+                except PindoraConflictError:
+                    response = cls.reschedule_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    if is_active and not response["access_code_is_active"]:
+                        cls.activate_access_code(obj=series)
+
+                    elif not is_active and response["access_code_is_active"]:
+                        cls.deactivate_access_code(obj=series)
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, reschedule and activate/deactivate instead.
+                except PindoraConflictError:
+                    response = cls.reschedule_access_code(obj=section)
+
+                    if is_active and not response["access_code_is_active"]:
+                        cls.activate_access_code(obj=section)
+
+                    elif not is_active and response["access_code_is_active"]:
+                        cls.deactivate_access_code(obj=section)
+
+    @classmethod
+    def _update_access_code_is_active_for_reservations(cls) -> None:
+        reservations: Iterable[Reservation] = Reservation.objects.has_incorrect_access_code_is_active().filter(
+            recurring_reservation__isnull=True,
+        )
+
+        for reservation in reservations:
+            with suppress(ExternalServiceError):
+                try:
+                    if reservation.access_code_should_be_active:
+                        cls.activate_access_code(obj=reservation)
 
                         # Access code was activated in Pindora, inform the user by email.
                         if reservation.access_code_is_active and reservation.access_code_generated_at:
                             EmailService.send_reservation_modified_access_code_email(reservation=reservation)
-                else:
-                    try:
-                        PindoraClient.deactivate_reservation_access_code(reservation=reservation)
 
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                    else:
+                        cls.deactivate_access_code(obj=reservation)
 
+                # If we think an access code has been generated, but it's not found in Pindora,
+                # set access code as not generated. New access code will be created by a background task.
+                except PindoraNotFoundError:
+                    reservation.access_code_generated_at = None
                     reservation.access_code_is_active = False
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+    @classmethod
+    def _update_access_code_is_active_for_series(cls) -> None:
+        all_series: Iterable[RecurringReservation] = (
+            RecurringReservation.objects.has_incorrect_access_code_is_active().filter(
+                allocated_time_slot__isnull=True,
+            )
+        )
+
+        for series in all_series:
+            with suppress(ExternalServiceError):
+                try:
+                    if series.should_have_active_access_code:
+                        cls.activate_access_code(obj=series)
+                    else:
+                        cls.deactivate_access_code(obj=series)
+
+                # If we think an access code has been generated, but it's not found in Pindora,
+                # set access code as not generated. New access code will be created by a background task.
+                except PindoraNotFoundError:
+                    series.reservations.update(access_code_generated_at=None, access_code_is_active=False)
+
+    @classmethod
+    def _update_access_code_is_active_for_seasonal_bookings(cls) -> None:
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.has_incorrect_access_code_is_active()
+
+        for section in sections:
+            with suppress(ExternalServiceError):
+                try:
+                    if section.should_have_active_access_code:
+                        cls.activate_access_code(obj=section)
+                    else:
+                        cls.deactivate_access_code(obj=section)
+
+                # If we think an access code has been generated, but it's not found in Pindora,
+                # set access code as not generated. New access code will be created by a background task.
+                except PindoraNotFoundError:
+                    Reservation.objects.for_application_section(section).update(
+                        access_code_generated_at=None,
+                        access_code_is_active=False,
+                    )
 
     @classmethod
     def _sync_reservation_access_code(cls, reservation: Reservation) -> None:
diff --git a/backend/tilavarauspalvelu/models/allocated_timeslot/queryset.py b/backend/tilavarauspalvelu/models/allocated_timeslot/queryset.py
index 8da60e1505..1a7a059bd8 100644
--- a/backend/tilavarauspalvelu/models/allocated_timeslot/queryset.py
+++ b/backend/tilavarauspalvelu/models/allocated_timeslot/queryset.py
@@ -12,7 +12,7 @@
     import datetime
 
     from tilavarauspalvelu.enums import Weekday
-    from tilavarauspalvelu.models import ReservationUnitOption
+    from tilavarauspalvelu.models import ApplicationRound, ReservationUnitOption
     from utils.date_utils import TimeSlot
 
 
@@ -104,5 +104,8 @@ def affecting_allocations(self, reservation_unit: int, begin_date: datetime.date
             )
         )
 
+    def for_application_round(self, ref: ApplicationRound | models.OuterRef) -> Self:
+        return self.filter(reservation_unit_option__application_section__application__application_round=ref)
+
 
 class AllocatedTimeSlotManager(models.Manager.from_queryset(AllocatedTimeSlotQuerySet)): ...
diff --git a/backend/tilavarauspalvelu/models/application_round/actions.py b/backend/tilavarauspalvelu/models/application_round/actions.py
index 0fbb28de45..75e0057b2b 100644
--- a/backend/tilavarauspalvelu/models/application_round/actions.py
+++ b/backend/tilavarauspalvelu/models/application_round/actions.py
@@ -2,11 +2,13 @@
 
 import dataclasses
 import datetime
+from contextlib import suppress
 from typing import TYPE_CHECKING
 
 from django.conf import settings
-from django.db import models, transaction
+from django.db import transaction
 from django.utils.translation import gettext_lazy as _
+from lookup_property import L
 
 from tilavarauspalvelu.enums import (
     ApplicantTypeChoice,
@@ -18,11 +20,14 @@
     Weekday,
 )
 from tilavarauspalvelu.exceptions import ApplicationRoundResetError
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError
 from tilavarauspalvelu.integrations.opening_hours.hauki_api_client import HaukiAPIClient
 from tilavarauspalvelu.integrations.opening_hours.time_span_element import TimeSpanElement
 from tilavarauspalvelu.models import (
     AllocatedTimeSlot,
     Application,
+    ApplicationSection,
     RecurringReservation,
     Reservation,
     ReservationUnitOption,
@@ -53,41 +58,23 @@ def reset_application_round_allocation(self) -> None:
         match self.application_round.status:
             case ApplicationRoundStatusChoice.IN_ALLOCATION:
                 # Unlock all reservation unit options, and remove all allocated time slots
-                lookup = (
-                    "application_section"  #
-                    "__application"
-                    "__application_round"
-                )
-                ReservationUnitOption.objects.filter(models.Q(**{lookup: self.application_round})).update(locked=False)
+                ReservationUnitOption.objects.for_application_round(self.application_round).update(locked=False)
+                AllocatedTimeSlot.objects.for_application_round(self.application_round).delete()
 
-                lookup = (
-                    "reservation_unit_option"  #
-                    "__application_section"
-                    "__application"
-                    "__application_round"
+            case ApplicationRoundStatusChoice.HANDLED:
+                # Check if there are any Pindora access codes and delete them before deleting the reservations
+                sections = ApplicationSection.objects.filter(
+                    L(should_have_active_access_code=True),
+                    application__application_round=self.application_round,
                 )
-                AllocatedTimeSlot.objects.filter(models.Q(**{lookup: self.application_round})).delete()
 
-            case ApplicationRoundStatusChoice.HANDLED:
+                for section in sections:
+                    with suppress(PindoraNotFoundError):
+                        PindoraService.delete_access_code(obj=section)
+
                 # Remove all recurring reservations, and set application round back to HANDLED
-                lookup = (
-                    "recurring_reservation"  #
-                    "__allocated_time_slot"
-                    "__reservation_unit_option"
-                    "__application_section"
-                    "__application"
-                    "__application_round"
-                )
-                Reservation.objects.filter(models.Q(**{lookup: self.application_round})).delete()
-
-                lookup = (
-                    "allocated_time_slot"  #
-                    "__reservation_unit_option"
-                    "__application_section"
-                    "__application"
-                    "__application_round"
-                )
-                RecurringReservation.objects.filter(models.Q(**{lookup: self.application_round})).delete()
+                Reservation.objects.for_application_round(self.application_round).delete()
+                RecurringReservation.objects.for_application_round(self.application_round).delete()
 
                 self.application_round.handled_date = None
                 self.application_round.save()
diff --git a/backend/tilavarauspalvelu/models/application_section/actions.py b/backend/tilavarauspalvelu/models/application_section/actions.py
index b535e551b2..8ca2c47b8b 100644
--- a/backend/tilavarauspalvelu/models/application_section/actions.py
+++ b/backend/tilavarauspalvelu/models/application_section/actions.py
@@ -31,11 +31,8 @@ def get_reservation_series(self) -> QuerySet[RecurringReservation]:
         )
 
     def get_reservations(self) -> ReservationQuerySet:
-        return Reservation.objects.filter(
+        return Reservation.objects.for_application_section(self.application_section).filter(
             user=self.application_section.application.user,
-            recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=(
-                self.application_section
-            ),
         )
 
     def get_last_reservation(self) -> Reservation | None:
diff --git a/backend/tilavarauspalvelu/models/application_section/model.py b/backend/tilavarauspalvelu/models/application_section/model.py
index 8976679afe..ec03915399 100644
--- a/backend/tilavarauspalvelu/models/application_section/model.py
+++ b/backend/tilavarauspalvelu/models/application_section/model.py
@@ -269,11 +269,8 @@ def should_have_active_access_code() -> bool:
         from tilavarauspalvelu.models import Reservation
 
         exists = Exists(
-            queryset=Reservation.objects.filter(
+            queryset=Reservation.objects.for_application_section(models.OuterRef("pk")).filter(
                 L(access_code_should_be_active=True),
-                recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=(
-                    models.OuterRef("pk")
-                ),
             ),
         )
         return exists  # type: ignore[return-value]  # noqa: RET504
@@ -282,10 +279,7 @@ def should_have_active_access_code() -> bool:
     def _(self) -> bool:
         from tilavarauspalvelu.models import Reservation
 
-        return Reservation.objects.filter(
-            L(access_code_should_be_active=True),
-            recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=self,
-        ).exists()
+        return Reservation.objects.for_application_section(self).filter(L(access_code_should_be_active=True)).exists()
 
     @lookup_property(skip_codegen=True)
     def is_access_code_is_active_correct() -> bool:
@@ -293,11 +287,8 @@ def is_access_code_is_active_correct() -> bool:
         from tilavarauspalvelu.models import Reservation
 
         exists = ~Exists(
-            queryset=Reservation.objects.filter(
+            queryset=Reservation.objects.for_application_section(models.OuterRef("pk")).filter(
                 L(is_access_code_is_active_correct=False),
-                recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=(
-                    models.OuterRef("pk")
-                ),
             ),
         )
         return exists  # type: ignore[return-value]  # noqa: RET504
@@ -306,7 +297,8 @@ def is_access_code_is_active_correct() -> bool:
     def _(self) -> bool:
         from tilavarauspalvelu.models import Reservation
 
-        return not Reservation.objects.filter(
-            L(is_access_code_is_active_correct=False),
-            recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=self,
-        ).exists()
+        return (
+            not Reservation.objects.for_application_section(self)
+            .filter(L(is_access_code_is_active_correct=False))
+            .exists()
+        )
diff --git a/backend/tilavarauspalvelu/models/application_section/queryset.py b/backend/tilavarauspalvelu/models/application_section/queryset.py
index f2196ae52d..6cadb419ff 100644
--- a/backend/tilavarauspalvelu/models/application_section/queryset.py
+++ b/backend/tilavarauspalvelu/models/application_section/queryset.py
@@ -7,13 +7,17 @@
 from helsinki_gdpr.models import SerializableMixin
 from lookup_property import L
 
-from tilavarauspalvelu.models import ReservationUnitOption
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
+from tilavarauspalvelu.models import Reservation, ReservationUnitOption
 
 __all__ = [
     "ApplicationSectionManager",
     "ApplicationSectionQuerySet",
 ]
 
+from utils.date_utils import local_datetime
+from utils.db import NowTT
+
 
 class ApplicationSectionQuerySet(models.QuerySet):
     def has_status_in(self, statuses: list[str]) -> Self:
@@ -51,6 +55,37 @@ def order_by_preferred_unit_name(self, *, lang: Literal["fi", "en", "sv"], desc:
             models.OrderBy(models.F(f"preferred_unit_name_{lang}"), descending=desc),
         )
 
+    def requiring_access_code(self) -> Self:
+        """Return all application sections that should have an access code but don't."""
+        return self.alias(
+            has_missing_access_codes=models.Exists(
+                queryset=Reservation.objects.for_application_section(models.OuterRef("pk")).filter(
+                    state=ReservationStateChoice.CONFIRMED,
+                    access_type=AccessType.ACCESS_CODE,
+                    access_code_generated_at=None,
+                    end__gt=NowTT(),
+                ),
+            )
+        ).filter(has_missing_access_codes=True)
+
+    def has_incorrect_access_code_is_active(self) -> Self:
+        """
+        Return all application sections where the access code is active
+        when it should be inactive, or vice versa.
+        """
+        return self.alias(
+            has_incorrect_access_codes=models.Exists(
+                queryset=Reservation.objects.for_application_section(models.OuterRef("pk")).filter(
+                    (
+                        (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
+                        | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
+                    ),
+                    access_code_generated_at__isnull=False,
+                    end__gt=local_datetime(),
+                ),
+            )
+        ).filter(has_incorrect_access_codes=True)
+
 
 class ApplicationSectionManager(SerializableMixin.SerializableManager.from_queryset(ApplicationSectionQuerySet)):
     """Contains custom queryset methods and GDPR serialization."""
diff --git a/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py b/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
index 58723c0a94..67b5b32fb3 100644
--- a/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
+++ b/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
@@ -1,12 +1,19 @@
 from __future__ import annotations
 
-from typing import Self
+from typing import TYPE_CHECKING, Self
 
 from dateutil.relativedelta import relativedelta
 from django.conf import settings
 from django.db import models
+from lookup_property import L
 
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice
+from tilavarauspalvelu.models import Reservation
 from utils.date_utils import local_datetime
+from utils.db import NowTT
+
+if TYPE_CHECKING:
+    from tilavarauspalvelu.models import ApplicationRound
 
 __all__ = [
     "RecurringReservationManager",
@@ -23,5 +30,43 @@ def old_empty_series(self) -> Self:
     def delete_empty_series(self) -> None:
         self.old_empty_series().delete()
 
+    def requiring_access_code(self) -> Self:
+        """Return all recurring reservations that should have an access code but don't."""
+        return self.alias(
+            has_missing_access_codes=models.Exists(
+                queryset=Reservation.objects.filter(
+                    recurring_reservation=models.OuterRef("pk"),
+                    state=ReservationStateChoice.CONFIRMED,
+                    access_type=AccessType.ACCESS_CODE,
+                    access_code_generated_at=None,
+                    end__gt=NowTT(),
+                ),
+            )
+        ).filter(has_missing_access_codes=True)
+
+    def has_incorrect_access_code_is_active(self) -> Self:
+        """
+        Return all recurring reservations where the access code is active
+        when it should be inactive, or vice versa.
+        """
+        return self.alias(
+            has_incorrect_access_codes=models.Exists(
+                queryset=Reservation.objects.filter(
+                    (
+                        (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
+                        | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
+                    ),
+                    access_code_generated_at__isnull=False,
+                    end__gt=local_datetime(),
+                    recurring_reservation=models.OuterRef("pk"),
+                ),
+            )
+        ).filter(has_incorrect_access_codes=True)
+
+    def for_application_round(self, ref: ApplicationRound | models.OuterRef) -> Self:
+        """Return all reservations series for the given application round."""
+        lookup = "allocated_time_slot__reservation_unit_option__application_section__application__application_round"
+        return self.filter(**{lookup: ref})
+
 
 class RecurringReservationManager(models.Manager.from_queryset(RecurringReservationQuerySet)): ...
diff --git a/backend/tilavarauspalvelu/models/reservation/queryset.py b/backend/tilavarauspalvelu/models/reservation/queryset.py
index c429c4154f..0a8b7f7100 100644
--- a/backend/tilavarauspalvelu/models/reservation/queryset.py
+++ b/backend/tilavarauspalvelu/models/reservation/queryset.py
@@ -10,14 +10,14 @@
 from helsinki_gdpr.models import SerializableMixin
 from lookup_property import L
 
-from tilavarauspalvelu.enums import OrderStatus, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.enums import AccessType, OrderStatus, ReservationStateChoice, ReservationTypeChoice
 from tilavarauspalvelu.integrations.keyless_entry import PindoraClient
 from tilavarauspalvelu.models import ReservationStatistic, ReservationStatisticsReservationUnit, ReservationUnit, Unit
 from tilavarauspalvelu.tasks import delete_pindora_reservation
 from utils.date_utils import local_datetime
 
 if TYPE_CHECKING:
-    from tilavarauspalvelu.models import ApplicationRound, Reservation
+    from tilavarauspalvelu.models import ApplicationRound, ApplicationSection, Reservation
     from tilavarauspalvelu.typing import AnyUser
 
 
@@ -346,6 +346,42 @@ def delete_with_inactive_payments(self) -> None:
 
         qs.delete()
 
+    def requiring_access_code(self) -> Self:
+        """Return all reservations that should have an access code but don't."""
+        return self.filter(
+            state=ReservationStateChoice.CONFIRMED,
+            access_type=AccessType.ACCESS_CODE,
+            access_code_generated_at=None,
+            end__gt=local_datetime(),
+        )
+
+    def has_incorrect_access_code_is_active(self) -> Self:
+        """Return all reservations where the access code is active when it should be inactive, or vice versa."""
+        return self.filter(
+            (
+                (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
+                | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
+            ),
+            access_code_generated_at__isnull=False,
+            end__gt=local_datetime(),
+        )
+
+    def for_application_section(self, ref: ApplicationSection | models.OuterRef) -> Self:
+        """Return all reservations for the given application section."""
+        return self.filter(recurring_reservation__allocated_time_slot__reservation_unit_option__application_section=ref)
+
+    def for_application_round(self, ref: ApplicationRound | models.OuterRef) -> Self:
+        """Return all reservations for the given application round."""
+        lookup = (
+            "recurring_reservation"  #
+            "__allocated_time_slot"
+            "__reservation_unit_option"
+            "__application_section"
+            "__application"
+            "__application_round"
+        )
+        return self.filter(**{lookup: ref})
+
 
 class ReservationManager(SerializableMixin.SerializableManager.from_queryset(ReservationQuerySet)):
     """Contains custom queryset methods and GDPR serialization."""
diff --git a/backend/tilavarauspalvelu/models/reservation_unit_option/queryset.py b/backend/tilavarauspalvelu/models/reservation_unit_option/queryset.py
index a2a1a467b8..fd64dd1b7c 100644
--- a/backend/tilavarauspalvelu/models/reservation_unit_option/queryset.py
+++ b/backend/tilavarauspalvelu/models/reservation_unit_option/queryset.py
@@ -1,14 +1,22 @@
 from __future__ import annotations
 
+from typing import TYPE_CHECKING
+
 from django.db import models
 
+if TYPE_CHECKING:
+    from tilavarauspalvelu.models import ApplicationRound
+
 __all__ = [
     "ReservationUnitOptionManager",
     "ReservationUnitOptionQuerySet",
 ]
 
 
-class ReservationUnitOptionQuerySet(models.QuerySet): ...
+class ReservationUnitOptionQuerySet(models.QuerySet):
+    def for_application_round(self, ref: ApplicationRound | models.OuterRef) -> models.QuerySet:
+        """Return all reservation unit options for the given application round."""
+        return self.filter(application_section__application__application_round=ref)
 
 
 class ReservationUnitOptionManager(models.Manager.from_queryset(ReservationUnitOptionQuerySet)): ...
diff --git a/backend/tilavarauspalvelu/tasks.py b/backend/tilavarauspalvelu/tasks.py
index 399526c03b..574ae150e2 100644
--- a/backend/tilavarauspalvelu/tasks.py
+++ b/backend/tilavarauspalvelu/tasks.py
@@ -1,11 +1,14 @@
 from __future__ import annotations
 
 import datetime
+import time
 import uuid
 from contextlib import suppress
 from decimal import Decimal
+from functools import wraps
 from typing import TYPE_CHECKING
 
+from django.core.cache import cache
 from django.db import transaction
 
 from config.celery import app
@@ -36,6 +39,8 @@
 if TYPE_CHECKING:
     from collections.abc import Collection
 
+    from celery.contrib.django.task import Task
+
     from tilavarauspalvelu.typing import QueryInfo
 
 
@@ -73,6 +78,44 @@
 ]
 
 
+def singleton_task[**P](task: Task) -> Task:
+    """
+    Lock task execution to a single concurrent instance based on a lock set in cache.
+    Must be added on top of the `@app.task` decorator!
+
+    See: https://docs.celeryq.dev/en/stable/tutorials/task-cookbook.html#ensuring-a-task-is-only-executed-one-at-a-time
+    """
+    task_func = task.run
+
+    @wraps(task_func)
+    def wrapper(*args: P.args, **kwargs: P.kwargs) -> None:
+        lock_timeout = 300  # 5 mins
+        timeout_leeway = 3  # Some leeway for cache operations
+
+        timeout_at = time.monotonic() + lock_timeout - timeout_leeway
+        lock_id: str = task.name
+
+        # Assuming the operation here is atomic, or at least close enough to be safe.
+        has_lock = cache.add(lock_id, 1, lock_timeout)
+
+        if not has_lock:
+            SentryLogger.log_message(
+                message="Task skipped since another instance is already running.",
+                details={"task_name": task.name},
+            )
+            return None
+
+        try:
+            return task_func(*args, **kwargs)
+        finally:
+            # Only release lock if we didn't time out (another task may have acquired the lock)
+            if time.monotonic() < timeout_at:
+                cache.delete(lock_id)
+
+    task.run = wrapper
+    return task
+
+
 @app.task(name="rebuild_space_tree_hierarchy")
 def rebuild_space_tree_hierarchy() -> None:
     with transaction.atomic():
@@ -291,6 +334,7 @@ def purge_image_cache(image_path: str) -> None:
     purge(image_path)
 
 
+@singleton_task
 @app.task(name="generate_reservation_series_from_allocations")
 @SentryLogger.log_if_raises("Failed to generate reservation series from allocations")
 def generate_reservation_series_from_allocations(application_round_id: int) -> None:
@@ -300,6 +344,9 @@ def generate_reservation_series_from_allocations(application_round_id: int) -> N
 
     application_round.actions.generate_reservations_from_allocations()
 
+    # Run creation of missing Pindora reservations ahead of its normal background task schedule
+    create_missing_pindora_reservations.delay()
+
 
 @app.task(name="delete_expired_applications")
 def delete_expired_applications() -> None:
@@ -360,15 +407,17 @@ def delete_pindora_reservation(reservation_uuid: str) -> None:
     PindoraClient.delete_reservation(reservation=uuid.UUID(reservation_uuid))
 
 
+@singleton_task
 @app.task(name="create_missing_pindora_reservations")
 def create_missing_pindora_reservations() -> None:
     from tilavarauspalvelu.integrations.keyless_entry import PindoraService
 
-    PindoraService.create_missing_reservations()
+    PindoraService.create_missing_access_codes()
 
 
+@singleton_task
 @app.task(name="update_pindora_access_code_is_active")
 def update_pindora_access_code_is_active() -> None:
     from tilavarauspalvelu.integrations.keyless_entry import PindoraService
 
-    PindoraService.update_pindora_access_code_is_active()
+    PindoraService.update_access_code_is_active()


Reviews:
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:05:41Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:07:45Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:16:07Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:18:13Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:52:09Z
user: login: vergama
body: Generally sound but with some observations.

Can the `create_missing_access_codes` task be run ahead of its normal schedule after `generate_reservations_from_allocations` has completed? If not, the on average 5 min wait is probably OK, though, since if the handler truly does check the results after finalizing app.round handling, it will most likely that at least that much time in any case (regardless of it producing reservations with that access type).

The `reset_application_round_allocation` admin action could attempt to delete (as background tasks) the access codes from Pindora if a round is rolled back from `HANDLED` to `IN_ALLOCATION` and its generated recurring reservations are thus deleted.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:52:37Z
user: login: ranta
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:22:29Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T07:30:57Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T08:52:27Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:53:16Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:53:25Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:53:38Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T10:53:45Z
user: login: ranta
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T11:23:50Z
user: login: vergama
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T11:35:22Z

Review comments:
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: vergama
body: Should maybe the same as in the sync triggered from UI upon rec.reservation modifications (`reservations.update_access_code_info`): update all the reservations in the series; i.e., those **not** requiring an access code with null/false.
created_at: 2025-03-11T13:05:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
+                        access_code_generated_at=response.access_code_generated_at,
+                        access_code_is_active=response.access_code_is_active,
+                    )
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=section)
+
+                    section.actions.get_reservations().requiring_access_code().update(
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: vergama
body: `get_reservations().update_access_code_info` would also be safe to call here?
created_at: 2025-03-11T13:07:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: vergama
body: I think upon conflict in fact could and should delegate to `reschedule_access_code`, to ensure that Pindora data matches Varaamo for the times of the reservations? (Which would then lead `reservations.update_access_code_info` as well.)
created_at: 2025-03-11T13:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
+                        access_code_generated_at=response.access_code_generated_at,
+                        access_code_is_active=response.access_code_is_active,
+                    )
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=section)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: vergama
body: Same as series, calling  `reschedule_access_code` instead of `get_access_code` would ensure that Pindora has the correct times all around (taking care of the possibility that it has the section's uuid but with a mismatching set of valid times).
created_at: 2025-03-11T13:18:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
+                        access_code_generated_at=response.access_code_generated_at,
+                        access_code_is_active=response.access_code_is_active,
+                    )
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=section)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: vergama
body: There's at least the scenario that a handled application round is reset to the previous state and some changes are made it the allocations before generating the recurring reservations anew, with potentially some differences across the reservations requiring an access code (but the section uuids will then be the same).
created_at: 2025-03-11T13:52:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -346,6 +346,15 @@ def delete_with_inactive_payments(self) -> None:
 
         qs.delete()
 
+    def requiring_access_code(self) -> Self:
+        """Return all reservations that should have an access code but don't."""
+        return self.filter(
+            state=ReservationStateChoice.CONFIRMED,
+            access_type=AccessType.ACCESS_CODE,
+            access_code_generated_at=None,
+            end__gt=local_datetime(),
+        )
path: backend/tilavarauspalvelu/models/reservation/queryset.py
user: login: ranta
body: - Missing `models.Q(type=ReservationTypeChoice.BLOCKED.value)` (Applies for other methods with the same name too)
- Why not make use of `L("access_code_should_be_active")` here 🤔 
created_at: 2025-03-11T15:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: ranta
body: I agree. If the create fails, that there is already some data in Pindora and maybe in Varaamo. In that case we should discard all data and update all reservations in the series
created_at: 2025-03-11T16:12:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -346,6 +346,15 @@ def delete_with_inactive_payments(self) -> None:
 
         qs.delete()
 
+    def requiring_access_code(self) -> Self:
+        """Return all reservations that should have an access code but don't."""
+        return self.filter(
+            state=ReservationStateChoice.CONFIRMED,
+            access_type=AccessType.ACCESS_CODE,
+            access_code_generated_at=None,
+            end__gt=local_datetime(),
+        )
path: backend/tilavarauspalvelu/models/reservation/queryset.py
user: login: vergama
body: Because of the `end__gt` condition (no need to attempt to fix reservations already in the past) -- but I suppose it could be written in the way to have both filters nevertheless.
created_at: 2025-03-12T07:30:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -346,6 +346,15 @@ def delete_with_inactive_payments(self) -> None:
 
         qs.delete()
 
+    def requiring_access_code(self) -> Self:
+        """Return all reservations that should have an access code but don't."""
+        return self.filter(
+            state=ReservationStateChoice.CONFIRMED,
+            access_type=AccessType.ACCESS_CODE,
+            access_code_generated_at=None,
+            end__gt=local_datetime(),
+        )
path: backend/tilavarauspalvelu/models/reservation/queryset.py
user: login: matti-lamppu
body: Shouldn't exclude BLOCKED because they _should_ have access codes, just inactive ones (see staff reservation endpoints)
created_at: 2025-03-12T08:52:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
+                        access_code_generated_at=response.access_code_generated_at,
+                        access_code_is_active=response.access_code_is_active,
+                    )
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=section)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: matti-lamppu
body: Calling reschedule now, with additional activate/deactivate calls if that state seems incorrect.
created_at: 2025-03-12T10:53:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: matti-lamppu
body: Calling reschedule now, with additional activate/deactivate calls if that state seems incorrect.
created_at: 2025-03-12T10:53:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: matti-lamppu
body: Calling reschedule now, with additional activate/deactivate calls if that state seems incorrect.
created_at: 2025-03-12T10:53:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -413,90 +410,169 @@ def sync_access_code(cls, obj: ApplicationSection | RecurringReservation | Reser
                 raise PindoraClientError(msg)
 
     @classmethod
-    def create_missing_reservations(cls) -> None:
-        """If a reservation failed to be created in Pindora, e.g. in the staff create endpoint, retry it here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                recurring_reservation=None,
-                state=ReservationStateChoice.CONFIRMED,
-                access_type=AccessType.ACCESS_CODE,
-                access_code_generated_at=None,
-                end__gt=local_datetime(),
-            )
-        )
+    def create_missing_access_codes(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if they should have an access code but don't,
+        create access codes for them. If access codes already exist, update Varaamo's access code info.
+        """
+        cls._create_missing_access_codes_for_reservations()
+        cls._create_missing_access_codes_for_series()
+        cls._create_missing_access_codes_for_seasonal_bookings()
 
-        if not reservations:
-            return
+    @classmethod
+    def update_access_code_is_active(cls) -> None:
+        """
+        Check reservations, series' and seasonal bookings, and if there are access codes active when they
+        should be inactive, or vice versa, update to what Varaamo thinks the active state should be.
+        If access codes are missing, set them as not generated.
+        """
+        cls._update_access_code_is_active_for_reservations()
+        cls._update_access_code_is_active_for_series()
+        cls._update_access_code_is_active_for_seasonal_bookings()
+
+    @classmethod
+    def _create_missing_access_codes_for_reservations(cls) -> None:
+        """
+        Create access codes for reservations that are missing them.
+        Do not include reservations in series or seasonal bookings.
+        """
+        reservations: Iterable[Reservation] = Reservation.objects.requiring_access_code().filter(
+            recurring_reservation__isnull=True
+        )
 
         for reservation in reservations:
             is_active = reservation.access_code_should_be_active
 
             with suppress(ExternalServiceError):
                 try:
-                    response = PindoraClient.create_reservation(reservation=reservation, is_active=is_active)
+                    cls.create_access_code(obj=reservation, is_active=is_active)
 
-                # If reservation already exists, fetch it instead.
+                # If reservation already exists, fetch it and update instead.
                 except PindoraConflictError:
-                    response = PindoraClient.get_reservation(reservation=reservation)
+                    response = cls.get_access_code(obj=reservation)
 
-                reservation.access_code_generated_at = response["access_code_generated_at"]
-                reservation.access_code_is_active = response["access_code_is_active"]
-
-                # Update reservations in loop so that in case of a timeout we have done some work.
-                reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
+                    reservation.access_code_generated_at = response.access_code_generated_at
+                    reservation.access_code_is_active = response.access_code_is_active
+                    reservation.save(update_fields=["access_code_generated_at", "access_code_is_active"])
 
                 # Since the access code was unable to be created when the reservation was initially made,
                 # send an email with the access code to the user.
                 if reservation.access_code_is_active and reservation.access_code_generated_at:
                     EmailService.send_reservation_modified_access_code_email(reservation=reservation)
 
     @classmethod
-    def update_pindora_access_code_is_active(cls) -> None:
-        """If a reservation's access code active state failed to update in Pindora, retry the change here."""
-        reservations: list[Reservation] = list(
-            Reservation.objects.filter(
-                (
-                    (models.Q(access_code_is_active=True) & L(access_code_should_be_active=False))
-                    | (models.Q(access_code_is_active=False) & L(access_code_should_be_active=True))
-                ),
-                recurring_reservation=None,
-                access_code_generated_at__isnull=False,
-                end__gt=local_datetime(),
-            )
+    def _create_missing_access_codes_for_series(cls) -> None:
+        """
+        Create access codes for recurring reservations that are missing them.
+        Do not include series in seasonal bookings.
+        """
+        all_series: Iterable[RecurringReservation] = RecurringReservation.objects.requiring_access_code().filter(
+            allocated_time_slot__isnull=True
         )
 
-        if not reservations:
-            return
+        for series in all_series:
+            is_active: bool = series.should_have_active_access_code  # type: ignore[attr-defined]
 
-        for reservation in reservations:
             with suppress(ExternalServiceError):
-                if reservation.access_code_should_be_active:
-                    try:
-                        PindoraClient.activate_reservation_access_code(reservation=reservation)
+                try:
+                    cls.create_access_code(obj=series, is_active=is_active)
 
-                    # If we think an access code has been generated, but it's not found in Pindora,
-                    # set `access_code_generated_at`. `create_missing_pindora_reservations` will
-                    # then create the access code if needed.
-                    except PindoraNotFoundError:
-                        reservation.access_code_generated_at = None
+                # If series already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=series)
 
-                    else:
-                        reservation.access_code_is_active = True
+                    series.reservations.requiring_access_code().update(
+                        access_code_generated_at=response.access_code_generated_at,
+                        access_code_is_active=response.access_code_is_active,
+                    )
+
+    @classmethod
+    def _create_missing_access_codes_for_seasonal_bookings(cls) -> None:
+        """Create access codes for application sections that are missing them."""
+        sections: Iterable[ApplicationSection] = ApplicationSection.objects.requiring_access_code()
+
+        for section in sections:
+            is_active: bool = section.should_have_active_access_code  # type: ignore[attr-defined]
+
+            with suppress(ExternalServiceError):
+                try:
+                    cls.create_access_code(obj=section, is_active=is_active)
+
+                # If seasonal booking already exists, fetch it and update instead.
+                except PindoraConflictError:
+                    response = cls.get_access_code(obj=section)
+
+                    section.actions.get_reservations().requiring_access_code().update(
path: backend/tilavarauspalvelu/integrations/keyless_entry/service.py
user: login: matti-lamppu
body: Calling reschedule now, with additional activate/deactivate calls if that state seems incorrect.
created_at: 2025-03-12T10:53:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: matti-lamppu
created_at: 2025-03-12T10:57:12Z
body: @vergama Added call to create missing pindora access codes after seasonal booking reservation generation. Made the tasks singletons by using a cache lock so that we can only run a single instance at a time.

In resetting application rounds, didn't make that a background job since it's harder to correct Pindora if the background job fails since the Varaamo reservation would be is missing.
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-12T13:42:10Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [3 New issues](https://sonarcloud.io/project/issues?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [98.3% Coverage on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1632)


author_association: NONE