Repository: City-of-Helsinki/tilavarauspalvelu-core
Pull request #1636: Add series change access code endpoint
Author: login: matti-lamppu
Labels: name: feature
description: Adds a new feature
Created: 2025-03-14T11:51:41Z | Merged: 2025-03-19T07:56:29Z

## 🛠️ Changelog
[//]: # "Describe the changes in this pull request here."

- Add change series access code endpoint to GraphQL schema

## 🧪 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-3834](https://helsinkisolutionoffice.atlassian.net/browse/TILA-3834)


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


Diff:
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py b/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
index 43ce80d713..cd2a4663d7 100644
--- a/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/helpers.py
@@ -37,6 +37,16 @@
     "ApplicationSectionReservationCancellationMutation",
     fields="future cancelled",
 )
+CHANGE_ACCESS_CODE_SERIES_MUTATION = build_mutation(
+    "changeReservationSeriesAccessCode",
+    "ReservationSeriesChangeAccessCodeMutation",
+    fields="accessCodeGeneratedAt accessCodeIsActive",
+)
+REPAIR_ACCESS_CODE_SERIES_MUTATION = build_mutation(
+    "repairReservationSeriesAccessCode",
+    "ReservationSeriesRepairAccessCodeMutation",
+    fields="accessCodeGeneratedAt accessCodeIsActive",
+)
 
 
 def get_minimal_series_data(reservation_unit: ReservationUnit, user: User, **overrides: Any) -> dict[str, Any]:
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code.py
new file mode 100644
index 0000000000..1deddcdb34
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import datetime
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.typing import PindoraAccessCodeModifyResponse
+from utils.date_utils import local_datetime
+
+from tests.factories import ApplicationSectionFactory, RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+from .helpers import CHANGE_ACCESS_CODE_SERIES_MUTATION
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert response.first_query_object == {
+        "accessCodeGeneratedAt": local_datetime(2024, 1, 1).astimezone(datetime.UTC).isoformat(),
+        "accessCodeIsActive": True,
+    }
+
+    assert PindoraService.change_access_code.call_count == 1
+    assert PindoraService.activate_access_code.call_count == 0
+
+
+@patch_method(
+    PindoraService.change_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(EmailService.send_seasonal_reservation_modified_series_access_code_email)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__in_seasonal_booking(graphql):
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        user=user,
+        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),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert PindoraService.change_access_code.call_count == 1
+    assert PindoraService.activate_access_code.call_count == 0
+
+    # Since connected to seasonal booking, email should be sent.
+    assert EmailService.send_seasonal_reservation_modified_series_access_code_email.called
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__activate_if_inactive(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert PindoraService.change_access_code.call_count == 1
+    assert PindoraService.activate_access_code.call_count == 1
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 2))
+def test_change_reservation_series_access_code__no_future_reservations(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Last reservation in the series has already ended."]
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__no_reservations(graphql):
+    series = RecurringReservationFactory.create()
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation series has no reservations."]
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__not_using_access_codes(graphql):
+    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.UNRESTRICTED,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == [
+        "Reservation series does not use access codes in any of its reservations.",
+    ]
+
+
+@patch_method(
+    PindoraService.change_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)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__not_requiring_active_access_codes(graphql):
+    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),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation series should not have active access code."]
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code_permissions.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code_permissions.py
new file mode 100644
index 0000000000..b7cd277b95
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_change_access_code_permissions.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.typing import PindoraAccessCodeModifyResponse
+from utils.date_utils import local_datetime
+
+from tests.factories import RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+from .helpers import CHANGE_ACCESS_CODE_SERIES_MUTATION
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(
+    PindoraService.change_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__regular_user(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_regular_user()
+
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "No permission to update."
+
+
+@patch_method(
+    PindoraService.change_access_code,
+    return_value=PindoraAccessCodeModifyResponse(
+        access_code_generated_at=local_datetime(2024, 1, 1),
+        access_code_is_active=True,
+    ),
+)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_change_reservation_series_access_code__general_admin(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    user = UserFactory.create_with_general_role()
+    graphql.force_login(user)
+
+    response = graphql(CHANGE_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code.py
new file mode 100644
index 0000000000..7db376506f
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+import datetime
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from utils.date_utils import local_datetime
+
+from tests.factories import ApplicationSectionFactory, RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+from .helpers import REPAIR_ACCESS_CODE_SERIES_MUTATION
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert response.first_query_object == {
+        "accessCodeGeneratedAt": local_datetime(2024, 1, 1).astimezone(datetime.UTC).isoformat(),
+        "accessCodeIsActive": True,
+    }
+
+    assert PindoraService.sync_access_code.call_count == 1
+
+
+@patch_method(PindoraService.sync_access_code)
+@patch_method(EmailService.send_seasonal_reservation_modified_series_access_code_email)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__in_seasonal_booking(graphql):
+    user = UserFactory.create()
+    section = ApplicationSectionFactory.create(
+        application__user=user,
+    )
+    series = RecurringReservationFactory.create(
+        user=user,
+        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),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert PindoraService.sync_access_code.call_count == 1
+
+    # Since connected to seasonal booking, email should be sent.
+    assert EmailService.send_seasonal_reservation_modified_series_access_code_email.called
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__activate_if_inactive(graphql):
+    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),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
+
+    assert PindoraService.sync_access_code.call_count == 1
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 2))
+def test_repair_reservation_series_access_code__no_future_reservations(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Last reservation in the series has already ended."]
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__no_reservations(graphql):
+    series = RecurringReservationFactory.create()
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation series has no reservations."]
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__not_using_access_codes(graphql):
+    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.UNRESTRICTED,
+        state=ReservationStateChoice.CONFIRMED,
+        type=ReservationTypeChoice.NORMAL,
+        access_code_is_active=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == [
+        "Reservation series does not use access codes in any of its reservations.",
+    ]
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__not_requiring_active_access_codes(graphql):
+    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),
+    )
+
+    graphql.login_with_superuser()
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "Mutation was unsuccessful."
+    assert response.field_error_messages() == ["Reservation series should not have active access code."]
diff --git a/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code_permissions.py b/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code_permissions.py
new file mode 100644
index 0000000000..55275ff4fa
--- /dev/null
+++ b/backend/tests/test_graphql_api/test_recurring_reservation/test_repair_access_code_permissions.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+import freezegun
+import pytest
+
+from tilavarauspalvelu.enums import AccessType, ReservationStateChoice, ReservationTypeChoice
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from utils.date_utils import local_datetime
+
+from tests.factories import RecurringReservationFactory, ReservationFactory, UserFactory
+from tests.helpers import patch_method
+
+from .helpers import REPAIR_ACCESS_CODE_SERIES_MUTATION
+
+pytestmark = [
+    pytest.mark.django_db,
+]
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__regular_user(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    graphql.login_with_regular_user()
+
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.error_message() == "No permission to update."
+
+
+@patch_method(PindoraService.sync_access_code)
+@freezegun.freeze_time(local_datetime(2024, 1, 1))
+def test_repair_reservation_series_access_code__general_admin(graphql):
+    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=True,
+        access_code_generated_at=local_datetime(2024, 1, 1),
+    )
+
+    user = UserFactory.create_with_general_role()
+    graphql.force_login(user)
+
+    response = graphql(REPAIR_ACCESS_CODE_SERIES_MUTATION, input_data={"pk": series.pk})
+
+    assert response.has_errors is False, response.errors
diff --git a/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_sync_access_code.py b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_sync_access_code.py
index 98bdc9622b..0399e1fa2c 100644
--- a/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_sync_access_code.py
+++ b/backend/tests/test_integrations/test_keyless_entry/test_pindora_service/test_sync_access_code.py
@@ -208,10 +208,10 @@ def test_sync_access_code__reservation__state_indicates_no_access_code(state):
     assert PindoraClient.reschedule_reservation.call_count == 0
 
 
+@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.create_access_code)
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.deactivate_access_code)
-@patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__reservation__in_series(is_active):
     series = RecurringReservationFactory.create()
@@ -229,16 +229,16 @@ def test_sync_access_code__reservation__in_series(is_active):
 
     PindoraService.sync_access_code(obj=reservation)
 
+    assert PindoraService.reschedule_access_code.call_count == 1
+    assert PindoraService.create_access_code.call_count == 0
     assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
     assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
-    assert PindoraService.create_access_code.call_count == 0
-    assert PindoraService.reschedule_access_code.call_count == 1
 
 
-@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("Not found"))
-@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("Not found"))
+@patch_method(PindoraService.reschedule_access_code, side_effect=PindoraNotFoundError("Not found"))
 @patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__reservation__in_series__not_found(is_active):
     series = RecurringReservationFactory.create()
@@ -256,17 +256,17 @@ def test_sync_access_code__reservation__in_series__not_found(is_active):
 
     PindoraService.sync_access_code(obj=reservation)
 
-    assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
-    assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
+    assert PindoraService.reschedule_access_code.call_count == 1
     assert PindoraService.create_access_code.call_count == 1
     assert PindoraService.create_access_code.call_args.kwargs["is_active"] is is_active
-    assert PindoraService.reschedule_access_code.call_count == 0
+    assert PindoraService.activate_access_code.call_count == 0
+    assert PindoraService.deactivate_access_code.call_count == 0
 
 
+@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.create_access_code)
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.deactivate_access_code)
-@patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__reservation__in_series__in_seasonal_booking(is_active):
     user = UserFactory.create()
@@ -291,16 +291,16 @@ def test_sync_access_code__reservation__in_series__in_seasonal_booking(is_active
 
     PindoraService.sync_access_code(obj=reservation)
 
+    assert PindoraService.reschedule_access_code.call_count == 1
+    assert PindoraService.create_access_code.call_count == 0
     assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
     assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
-    assert PindoraService.create_access_code.call_count == 0
-    assert PindoraService.reschedule_access_code.call_count == 1
 
 
-@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("Not found"))
-@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("Not found"))
+@patch_method(PindoraService.reschedule_access_code, side_effect=PindoraNotFoundError("Not found"))
 @patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__reservation__in_series__in_seasonal_booking__not_found(is_active):
     user = UserFactory.create()
@@ -325,17 +325,17 @@ def test_sync_access_code__reservation__in_series__in_seasonal_booking__not_foun
 
     PindoraService.sync_access_code(obj=reservation)
 
-    assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
-    assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
+    assert PindoraService.reschedule_access_code.call_count == 1
     assert PindoraService.create_access_code.call_count == 1
     assert PindoraService.create_access_code.call_args.kwargs["is_active"] is is_active
-    assert PindoraService.reschedule_access_code.call_count == 0
+    assert PindoraService.activate_access_code.call_count == 0
+    assert PindoraService.deactivate_access_code.call_count == 0
 
 
+@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.create_access_code)
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.deactivate_access_code)
-@patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__series(is_active):
     series = RecurringReservationFactory.create()
@@ -353,16 +353,16 @@ def test_sync_access_code__series(is_active):
 
     PindoraService.sync_access_code(obj=series)
 
+    assert PindoraService.reschedule_access_code.call_count == 1
+    assert PindoraService.create_access_code.call_count == 0
     assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
     assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
-    assert PindoraService.create_access_code.call_count == 0
-    assert PindoraService.reschedule_access_code.call_count == 1
 
 
-@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("Not found"))
-@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("Not found"))
+@patch_method(PindoraService.reschedule_access_code, side_effect=PindoraNotFoundError("Not found"))
 @patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__series__not_found(is_active):
     series = RecurringReservationFactory.create()
@@ -380,17 +380,17 @@ def test_sync_access_code__series__not_found(is_active):
 
     PindoraService.sync_access_code(obj=series)
 
-    assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
-    assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
+    assert PindoraService.reschedule_access_code.call_count == 1
     assert PindoraService.create_access_code.call_count == 1
     assert PindoraService.create_access_code.call_args.kwargs["is_active"] is is_active
-    assert PindoraService.reschedule_access_code.call_count == 0
+    assert PindoraService.activate_access_code.call_count == 0
+    assert PindoraService.deactivate_access_code.call_count == 0
 
 
+@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.create_access_code)
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.deactivate_access_code)
-@patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__series__in_seasonal_booking(is_active):
     user = UserFactory.create()
@@ -415,16 +415,16 @@ def test_sync_access_code__series__in_seasonal_booking(is_active):
 
     PindoraService.sync_access_code(obj=series)
 
+    assert PindoraService.reschedule_access_code.call_count == 1
+    assert PindoraService.create_access_code.call_count == 0
     assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
     assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
-    assert PindoraService.create_access_code.call_count == 0
-    assert PindoraService.reschedule_access_code.call_count == 1
 
 
-@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("Not found"))
-@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("Not found"))
+@patch_method(PindoraService.reschedule_access_code, side_effect=PindoraNotFoundError("Not found"))
 @patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__series__in_seasonal_booking__not_found(is_active):
     user = UserFactory.create()
@@ -449,17 +449,17 @@ def test_sync_access_code__series__in_seasonal_booking__not_found(is_active):
 
     PindoraService.sync_access_code(obj=series)
 
-    assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
-    assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
+    assert PindoraService.reschedule_access_code.call_count == 1
     assert PindoraService.create_access_code.call_count == 1
     assert PindoraService.create_access_code.call_args.kwargs["is_active"] is is_active
-    assert PindoraService.reschedule_access_code.call_count == 0
+    assert PindoraService.activate_access_code.call_count == 0
+    assert PindoraService.deactivate_access_code.call_count == 0
 
 
+@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.create_access_code)
 @patch_method(PindoraService.activate_access_code)
 @patch_method(PindoraService.deactivate_access_code)
-@patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__seasonal_booking(is_active):
     user = UserFactory.create()
@@ -484,16 +484,16 @@ def test_sync_access_code__seasonal_booking(is_active):
 
     PindoraService.sync_access_code(obj=section)
 
+    assert PindoraService.reschedule_access_code.call_count == 1
+    assert PindoraService.create_access_code.call_count == 0
     assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
     assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
-    assert PindoraService.create_access_code.call_count == 0
-    assert PindoraService.reschedule_access_code.call_count == 1
 
 
-@patch_method(PindoraService.activate_access_code, side_effect=PindoraNotFoundError("Not found"))
-@patch_method(PindoraService.deactivate_access_code, side_effect=PindoraNotFoundError("Not found"))
+@patch_method(PindoraService.reschedule_access_code, side_effect=PindoraNotFoundError("Not found"))
 @patch_method(PindoraService.create_access_code)
-@patch_method(PindoraService.reschedule_access_code)
+@patch_method(PindoraService.activate_access_code)
+@patch_method(PindoraService.deactivate_access_code)
 @pytest.mark.parametrize("is_active", [True, False])
 def test_sync_access_code__seasonal_booking__not_found(is_active):
     user = UserFactory.create()
@@ -518,8 +518,8 @@ def test_sync_access_code__seasonal_booking__not_found(is_active):
 
     PindoraService.sync_access_code(obj=section)
 
-    assert PindoraService.activate_access_code.call_count == (1 if is_active else 0)
-    assert PindoraService.deactivate_access_code.call_count == (0 if is_active else 1)
+    assert PindoraService.reschedule_access_code.call_count == 1
     assert PindoraService.create_access_code.call_count == 1
     assert PindoraService.create_access_code.call_args.kwargs["is_active"] is is_active
-    assert PindoraService.reschedule_access_code.call_count == 0
+    assert PindoraService.activate_access_code.call_count == 0
+    assert PindoraService.deactivate_access_code.call_count == 0
diff --git a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
index 4846ab2f44..988148969d 100644
--- a/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
+++ b/backend/tilavarauspalvelu/api/graphql/extensions/error_codes.py
@@ -100,13 +100,16 @@
 RESERVATION_MODIFICATION_NOT_ALLOWED = "RESERVATION_MODIFICATION_NOT_ALLOWED"
 RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE = "RESERVATION_NOT_WITHIN_ALLOWED_TIME_RANGE"
 RESERVATION_SERIES_ALREADY_STARTED = "RESERVATION_SERIES_ALREADY_STARTED"
+RESERVATION_SERIES_HAS_ENDED = "RESERVATION_SERIES_HAS_ENDED"
 RESERVATION_SERIES_INVALID_RECURRENCE_IN_DAYS = " RESERVATION_SERIES_INVALID_RECURRENCE_IN_DAYS"
 RESERVATION_SERIES_INVALID_START_INTERVAL = "RESERVATION_SERIES_INVALID_START_INTERVAL"
 RESERVATION_SERIES_INVALID_WEEKDAY = "RESERVATION_SERIES_INVALID_WEEKDAY"
 RESERVATION_SERIES_NO_FUTURE_RESERVATIONS = "RESERVATION_SERIES_NO_FUTURE_RESERVATIONS"
 RESERVATION_SERIES_NO_RESERVATION = "RESERVATION_SERIES_NO_RESERVATION"
+RESERVATION_SERIES_NOT_ACCESS_CODE = "RESERVATION_SERIES_NOT_ACCESS_CODE"
 RESERVATION_SERIES_NOT_OPEN = "RESERVATION_SERIES_NOT_OPEN"
 RESERVATION_SERIES_OVERLAPS = "RESERVATION_SERIES_OVERLAPS"
+RESERVATION_SERIES_SHOULD_NOT_HAVE_ACTIVE_ACCESS_CODE = "RESERVATION_SERIES_SHOULD_NOT_HAVE_ACTIVE_ACCESS_CODE"
 RESERVATION_STATE_CHANGE_NOT_ALLOWED = "RESERVATION_STATE_CHANGE_NOT_ALLOWED"
 RESERVATION_TIME_DOES_NOT_MATCH_ALLOWED_INTERVAL = "RESERVATION_TIME_DOES_NOT_MATCH_ALLOWED_INTERVAL"
 RESERVATION_TYPE_NOT_ALLOWED = "RESERVATION_TYPE_NOT_ALLOWED"
diff --git a/backend/tilavarauspalvelu/api/graphql/mutations.py b/backend/tilavarauspalvelu/api/graphql/mutations.py
index ba7657dc97..5436bbcbd8 100644
--- a/backend/tilavarauspalvelu/api/graphql/mutations.py
+++ b/backend/tilavarauspalvelu/api/graphql/mutations.py
@@ -50,8 +50,10 @@
 from .types.purpose.mutations import PurposeCreateMutation, PurposeUpdateMutation
 from .types.recurring_reservation.mutations import (
     ReservationSeriesAddMutation,
+    ReservationSeriesChangeAccessCodeMutation,
     ReservationSeriesCreateMutation,
     ReservationSeriesDenyMutation,
+    ReservationSeriesRepairAccessCodeMutation,
     ReservationSeriesRescheduleMutation,
     ReservationSeriesUpdateMutation,
 )
@@ -124,8 +126,10 @@
     "ReservationRefundMutation",
     "ReservationRequiresHandlingMutation",
     "ReservationSeriesAddMutation",
+    "ReservationSeriesChangeAccessCodeMutation",
     "ReservationSeriesCreateMutation",
     "ReservationSeriesDenyMutation",
+    "ReservationSeriesRepairAccessCodeMutation",
     "ReservationSeriesRescheduleMutation",
     "ReservationSeriesUpdateMutation",
     "ReservationStaffAdjustTimeMutation",
diff --git a/backend/tilavarauspalvelu/api/graphql/schema.py b/backend/tilavarauspalvelu/api/graphql/schema.py
index 8175694a82..3685084383 100644
--- a/backend/tilavarauspalvelu/api/graphql/schema.py
+++ b/backend/tilavarauspalvelu/api/graphql/schema.py
@@ -54,8 +54,10 @@
     ReservationRefundMutation,
     ReservationRequiresHandlingMutation,
     ReservationSeriesAddMutation,
+    ReservationSeriesChangeAccessCodeMutation,
     ReservationSeriesCreateMutation,
     ReservationSeriesDenyMutation,
+    ReservationSeriesRepairAccessCodeMutation,
     ReservationSeriesRescheduleMutation,
     ReservationSeriesUpdateMutation,
     ReservationStaffAdjustTimeMutation,
@@ -364,16 +366,24 @@ class Mutation(graphene.ObjectType):
     require_handling_for_reservation = ReservationRequiresHandlingMutation.Field()
     update_reservation_working_memo = ReservationWorkingMemoMutation.Field()
     adjust_reservation_time = ReservationAdjustTimeMutation.Field()
+    #
+    # Staff reservations
     staff_adjust_reservation_time = ReservationStaffAdjustTimeMutation.Field()
     staff_reservation_modify = ReservationStaffModifyMutation.Field()
+    staff_change_reservation_access_code = ReservationStaffChangeAccessCodeMutation.Field()
+    staff_repair_reservation_access_code = ReservationStaffRepairAccessCodeMutation.Field()
+    #
+    # Reservation series
     create_reservation_series = ReservationSeriesCreateMutation.Field()
     update_reservation_series = ReservationSeriesUpdateMutation.Field()
     add_reservation_to_series = ReservationSeriesAddMutation.Field()
     reschedule_reservation_series = ReservationSeriesRescheduleMutation.Field()
     deny_reservation_series = ReservationSeriesDenyMutation.Field()
+    change_reservation_series_access_code = ReservationSeriesChangeAccessCodeMutation.Field()
+    repair_reservation_series_access_code = ReservationSeriesRepairAccessCodeMutation.Field()
+    #
+    # Orders
     refresh_order = RefreshOrderMutation.Field()
-    staff_change_reservation_access_code = ReservationStaffChangeAccessCodeMutation.Field()
-    staff_repair_reservation_access_code = ReservationStaffRepairAccessCodeMutation.Field()
     #
     # User
     update_current_user = CurrentUserUpdateMutation.Field()
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py
index e47cdf5ea2..cb45562bae 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/mutations.py
@@ -10,20 +10,24 @@
 from .permissions import RecurringReservationPermission
 from .serializers import (
     ReservationSeriesAddReservationSerializer,
+    ReservationSeriesChangeAccessCodeSerializer,
     ReservationSeriesCreateSerializer,
     ReservationSeriesDenyInputSerializer,
     ReservationSeriesDenyOutputSerializer,
     ReservationSeriesRescheduleSerializer,
     ReservationSeriesUpdateSerializer,
 )
+from .serializers.repair_access_code import ReservationSeriesRepairAccessCodeSerializer
 
 if TYPE_CHECKING:
-    from tilavarauspalvelu.models import RecurringReservation
+    from tilavarauspalvelu.models import RecurringReservation, Reservation
 
 __all__ = [
     "ReservationSeriesAddMutation",
+    "ReservationSeriesChangeAccessCodeMutation",
     "ReservationSeriesCreateMutation",
     "ReservationSeriesDenyMutation",
+    "ReservationSeriesRepairAccessCodeMutation",
     "ReservationSeriesRescheduleMutation",
     "ReservationSeriesUpdateMutation",
 ]
@@ -66,3 +70,31 @@ def get_serializer_output(cls, instance: RecurringReservation) -> dict[str, Any]
             "denied": future_reservations.filter(state=ReservationStateChoice.DENIED).count(),
             "future": future_reservations.count(),
         }
+
+
+class ReservationSeriesChangeAccessCodeMutation(UpdateMutation):
+    class Meta:
+        serializer_class = ReservationSeriesChangeAccessCodeSerializer
+        permission_classes = [RecurringReservationPermission]
+
+    @classmethod
+    def get_serializer_output(cls, instance: RecurringReservation) -> dict[str, Any]:
+        last_reservation: Reservation = instance.reservations.requires_active_access_code().last()
+        return {
+            "access_code_generated_at": last_reservation.access_code_generated_at,
+            "access_code_is_active": last_reservation.access_code_is_active,
+        }
+
+
+class ReservationSeriesRepairAccessCodeMutation(UpdateMutation):
+    class Meta:
+        serializer_class = ReservationSeriesRepairAccessCodeSerializer
+        permission_classes = [RecurringReservationPermission]
+
+    @classmethod
+    def get_serializer_output(cls, instance: RecurringReservation) -> dict[str, Any]:
+        last_reservation: Reservation = instance.reservations.requires_active_access_code().last()
+        return {
+            "access_code_generated_at": last_reservation.access_code_generated_at,
+            "access_code_is_active": last_reservation.access_code_is_active,
+        }
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/__init__.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/__init__.py
index 035c061ee2..2e7a0aed2b 100644
--- a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/__init__.py
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/__init__.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from .add_serializer import ReservationSeriesAddReservationSerializer
+from .change_access_code import ReservationSeriesChangeAccessCodeSerializer
 from .create_seriealizer import ReservationSeriesCreateSerializer
 from .deny_serializer import ReservationSeriesDenyInputSerializer, ReservationSeriesDenyOutputSerializer
 from .reschedule_serializer import ReservationSeriesRescheduleSerializer
@@ -8,6 +9,7 @@
 
 __all__ = [
     "ReservationSeriesAddReservationSerializer",
+    "ReservationSeriesChangeAccessCodeSerializer",
     "ReservationSeriesCreateSerializer",
     "ReservationSeriesDenyInputSerializer",
     "ReservationSeriesDenyOutputSerializer",
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/change_access_code.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/change_access_code.py
new file mode 100644
index 0000000000..dc90ddcc29
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/change_access_code.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from contextlib import suppress
+from typing import Any
+
+from graphene_django_extensions import NestingModelSerializer
+from rest_framework.fields import BooleanField, DateTimeField, IntegerField
+
+from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError
+from tilavarauspalvelu.models import RecurringReservation, Reservation
+from utils.external_service.errors import ExternalServiceError
+
+__all__ = [
+    "ReservationSeriesChangeAccessCodeSerializer",
+]
+
+
+class ReservationSeriesChangeAccessCodeSerializer(NestingModelSerializer):
+    """Change the access code of a reservation series."""
+
+    instance: RecurringReservation
+
+    pk = IntegerField(required=True)
+
+    access_code_generated_at = DateTimeField(allow_null=True, read_only=True)
+    access_code_is_active = BooleanField(read_only=True)
+
+    class Meta:
+        model = RecurringReservation
+        fields = [
+            "pk",
+            "access_code_generated_at",
+            "access_code_is_active",
+        ]
+
+    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
+        self.instance.validators.validate_series_has_ongoing_or_future_reservations()
+        self.instance.validators.validate_has_access_code_access_type()
+        self.instance.validators.validate_requires_active_access_code()
+        return data
+
+    def update(self, instance: RecurringReservation, validated_data: dict[str, Any]) -> RecurringReservation:  # noqa: ARG002
+        try:
+            response = PindoraService.change_access_code(obj=instance)
+
+        except PindoraNotFoundError:
+            if instance.allocated_time_slot is None:
+                instance.reservations.update(access_code_generated_at=None, access_code_is_active=False)
+                return instance
+
+            section = instance.allocated_time_slot.reservation_unit_option.application_section
+            Reservation.objects.for_application_section(section).update(
+                access_code_generated_at=None,
+                access_code_is_active=False,
+            )
+            return instance
+
+        if not response["access_code_is_active"]:
+            with suppress(ExternalServiceError):
+                PindoraService.activate_access_code(obj=instance)
+
+        if instance.allocated_time_slot is not None:
+            # Send email to all reservation series in the same application section where the access code is active.
+            # TODO: Should send a single email with all of the series' info.
+            section = instance.allocated_time_slot.reservation_unit_option.application_section
+
+            series: RecurringReservation
+            for series in section.actions.get_reservation_series().should_have_active_access_code():
+                EmailService.send_seasonal_reservation_modified_series_access_code_email(series)
+
+        return instance
diff --git a/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/repair_access_code.py b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/repair_access_code.py
new file mode 100644
index 0000000000..1bb33a11ec
--- /dev/null
+++ b/backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/repair_access_code.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from typing import Any
+
+from graphene_django_extensions import NestingModelSerializer
+from rest_framework.fields import BooleanField, DateTimeField, IntegerField
+
+from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.models import RecurringReservation
+
+__all__ = [
+    "ReservationSeriesRepairAccessCodeSerializer",
+]
+
+
+class ReservationSeriesRepairAccessCodeSerializer(NestingModelSerializer):
+    """
+    Synchronize the state of the reservation series' access code between Varaamo and Pindora
+    to what Varaamo thinks is should be its correct state.
+    """
+
+    instance: RecurringReservation
+
+    pk = IntegerField(required=True)
+
+    access_code_generated_at = DateTimeField(allow_null=True, read_only=True)
+    access_code_is_active = BooleanField(read_only=True)
+
+    class Meta:
+        model = RecurringReservation
+        fields = [
+            "pk",
+            "access_code_generated_at",
+            "access_code_is_active",
+        ]
+
+    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
+        self.instance.validators.validate_series_has_ongoing_or_future_reservations()
+        self.instance.validators.validate_has_access_code_access_type()
+        self.instance.validators.validate_requires_active_access_code()
+        return data
+
+    def update(self, instance: RecurringReservation, validated_data: dict[str, Any]) -> RecurringReservation:  # noqa: ARG002
+        PindoraService.sync_access_code(obj=instance)
+
+        if instance.allocated_time_slot is not None:
+            # Send email to all reservation series in the same application section where the access code is active.
+            # TODO: Should send a single email with all of the series' info.
+            section = instance.allocated_time_slot.reservation_unit_option.application_section
+
+            series: RecurringReservation
+            for series in section.actions.get_reservation_series().should_have_active_access_code():
+                EmailService.send_seasonal_reservation_modified_series_access_code_email(series)
+
+        return instance
diff --git a/backend/tilavarauspalvelu/integrations/keyless_entry/service.py b/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
index 75072db663..058c8ad8d2 100644
--- a/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
+++ b/backend/tilavarauspalvelu/integrations/keyless_entry/service.py
@@ -612,17 +612,17 @@ def _sync_series_or_seasonal_booking_access_code(cls, obj: RecurringReservation
         should_be_active: bool = obj.should_have_active_access_code  # type: ignore[attr-defined]
 
         try:
-            if should_be_active:
-                cls.activate_access_code(obj=obj)
-            else:
-                cls.deactivate_access_code(obj=obj)
-
+            # Reschedule first, so that if there are any reservations that didn't have an access code before,
+            # but have one after a reschedule, they will know that the access code has been generated.
+            cls.reschedule_access_code(obj=obj)
         except PindoraNotFoundError:
             # Reservation series and application sections always have an access code, even if it's not active.
             cls.create_access_code(obj=obj, is_active=should_be_active)
-
         else:
-            cls.reschedule_access_code(obj=obj)
+            if should_be_active:
+                cls.activate_access_code(obj=obj)
+            else:
+                cls.deactivate_access_code(obj=obj)
 
     @classmethod
     def _parse_series_validity_info(
diff --git a/backend/tilavarauspalvelu/models/application_section/actions.py b/backend/tilavarauspalvelu/models/application_section/actions.py
index 8ca2c47b8b..5c408d20ae 100644
--- a/backend/tilavarauspalvelu/models/application_section/actions.py
+++ b/backend/tilavarauspalvelu/models/application_section/actions.py
@@ -6,9 +6,8 @@
 from tilavarauspalvelu.models import RecurringReservation, Reservation
 
 if TYPE_CHECKING:
-    from django.db.models import QuerySet
-
     from tilavarauspalvelu.models import ApplicationSection
+    from tilavarauspalvelu.models.recurring_reservation.queryset import RecurringReservationQuerySet
     from tilavarauspalvelu.models.reservation.queryset import ReservationQuerySet
 
 
@@ -21,7 +20,7 @@
 class ApplicationSectionActions:
     application_section: ApplicationSection
 
-    def get_reservation_series(self) -> QuerySet[RecurringReservation]:
+    def get_reservation_series(self) -> RecurringReservationQuerySet:
         return (
             RecurringReservation.objects.filter(
                 allocated_time_slot__reservation_unit_option__application_section=self.application_section
diff --git a/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py b/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
index 67b5b32fb3..979a0dff96 100644
--- a/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
+++ b/backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
@@ -44,6 +44,10 @@ def requiring_access_code(self) -> Self:
             )
         ).filter(has_missing_access_codes=True)
 
+    def should_have_active_access_code(self) -> Self:
+        """Return all recurring reservations should have an active access code."""
+        return self.filter(L(should_have_active_access_code=True))
+
     def has_incorrect_access_code_is_active(self) -> Self:
         """
         Return all recurring reservations where the access code is active
diff --git a/backend/tilavarauspalvelu/models/recurring_reservation/validators.py b/backend/tilavarauspalvelu/models/recurring_reservation/validators.py
index 420e5a5569..dd5e9e1d0f 100644
--- a/backend/tilavarauspalvelu/models/recurring_reservation/validators.py
+++ b/backend/tilavarauspalvelu/models/recurring_reservation/validators.py
@@ -7,12 +7,13 @@
 from rest_framework.exceptions import ValidationError
 
 from tilavarauspalvelu.api.graphql.extensions import error_codes
+from tilavarauspalvelu.enums import AccessType
 from tilavarauspalvelu.integrations.opening_hours.reservable_time_span_client import ReservableTimeSpanClient
-from utils.date_utils import local_date
+from utils.date_utils import local_date, local_datetime
 
 if TYPE_CHECKING:
     from tilavarauspalvelu.enums import ReservationStartInterval
-    from tilavarauspalvelu.models import RecurringReservation
+    from tilavarauspalvelu.models import RecurringReservation, Reservation
 
 __all__ = [
     "ReservationSeriesValidator",
@@ -63,3 +64,23 @@ def validate_series_time_slots(
         if not is_valid_start_interval:
             msg = f"Reservation start time does not match the allowed interval of {interval_minutes} minutes."
             raise ValidationError(msg, code=error_codes.RESERVATION_TIME_DOES_NOT_MATCH_ALLOWED_INTERVAL)
+
+    def validate_has_access_code_access_type(self) -> None:
+        if AccessType.ACCESS_CODE not in self.series.used_access_types:
+            msg = "Reservation series does not use access codes in any of its reservations."
+            raise ValidationError(msg, code=error_codes.RESERVATION_SERIES_NOT_ACCESS_CODE)
+
+    def validate_requires_active_access_code(self) -> None:
+        if not self.series.should_have_active_access_code:
+            msg = "Reservation series should not have active access code."
+            raise ValidationError(msg, code=error_codes.RESERVATION_SERIES_SHOULD_NOT_HAVE_ACTIVE_ACCESS_CODE)
+
+    def validate_series_has_ongoing_or_future_reservations(self) -> None:
+        reservation: Reservation | None = self.series.reservations.last()
+        if reservation is None:
+            msg = "Reservation series has no reservations."
+            raise ValidationError(msg, code=error_codes.RESERVATION_SERIES_NO_RESERVATION)
+
+        if reservation.end < local_datetime():
+            msg = "Last reservation in the series has already ended."
+            raise ValidationError(msg, code=error_codes.RESERVATION_SERIES_HAS_ENDED)


Reviews:
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T11:58:41Z
user: login: vergama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T12:56:31Z
user: login: vergama
body: Looks good but the comment
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T13:11:33Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T13:20:02Z
user: login: ranta
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T12:41:24Z
user: login: matti-lamppu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T07:41:40Z

Review comments:
diff_hunk: @@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from contextlib import suppress
+from typing import Any
+
+from graphene_django_extensions import NestingModelSerializer
+from rest_framework.fields import BooleanField, DateTimeField, IntegerField
+
+from tilavarauspalvelu.integrations.email.main import EmailService
+from tilavarauspalvelu.integrations.keyless_entry import PindoraService
+from tilavarauspalvelu.integrations.keyless_entry.exceptions import PindoraNotFoundError
+from tilavarauspalvelu.models import RecurringReservation, Reservation
+from utils.external_service.errors import ExternalServiceError
+
+__all__ = [
+    "ReservationSeriesChangeAccessCodeSerializer",
+]
+
+
+class ReservationSeriesChangeAccessCodeSerializer(NestingModelSerializer):
+    """Change the access code of a reservation series."""
+
+    instance: RecurringReservation
+
+    pk = IntegerField(required=True)
+
+    access_code_generated_at = DateTimeField(allow_null=True, read_only=True)
+    access_code_is_active = BooleanField(read_only=True)
+
+    class Meta:
+        model = RecurringReservation
+        fields = [
+            "pk",
+            "access_code_generated_at",
+            "access_code_is_active",
+        ]
+
+    def validate(self, data: dict[str, Any]) -> dict[str, Any]:
+        self.instance.validators.validate_series_has_ongoing_or_future_reservations()
+        self.instance.validators.validate_has_access_code_access_type()
+        self.instance.validators.validate_requires_active_access_code()
+        return data
+
+    def update(self, instance: RecurringReservation, validated_data: dict[str, Any]) -> RecurringReservation:  # noqa: ARG002
+        try:
+            response = PindoraService.change_access_code(obj=instance)
+
+        except PindoraNotFoundError:
+            if instance.allocated_time_slot is None:
+                instance.reservations.update(access_code_generated_at=None, access_code_is_active=False)
+                return instance
+
+            section = instance.allocated_time_slot.reservation_unit_option.application_section
+            Reservation.objects.for_application_section(section).update(
+                access_code_generated_at=None,
+                access_code_is_active=False,
+            )
+            return instance
+
+        if not response["access_code_is_active"]:
+            with suppress(ExternalServiceError):
+                PindoraService.activate_access_code(obj=instance)
+
+        if instance.allocated_time_slot is not None:
+            # Send email to all reservation series in the same application section where the access code is active.
+            # TODO: Should send a single email with all of the series' info.
path: backend/tilavarauspalvelu/api/graphql/types/recurring_reservation/serializers/change_access_code.py
user: login: matti-lamppu
body: Split to https://helsinkisolutionoffice.atlassian.net/browse/TILA-3900
created_at: 2025-03-14T11:58:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,6 +44,10 @@ def requiring_access_code(self) -> Self:
             )
         ).filter(has_missing_access_codes=True)
 
+    def has_active_access_code(self) -> Self:
+        """Return all recurring reservations that should have an active access code but don't."""
path: backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
user: login: vergama
body: comment does not match
created_at: 2025-03-14T12:56:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,6 +44,10 @@ def requiring_access_code(self) -> Self:
             )
         ).filter(has_missing_access_codes=True)
 
+    def has_active_access_code(self) -> Self:
+        """Return all recurring reservations that should have an active access code but don't."""
path: backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
user: login: matti-lamppu
body: Fixed
created_at: 2025-03-14T13:20:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,6 +44,10 @@ def requiring_access_code(self) -> Self:
             )
         ).filter(has_missing_access_codes=True)
 
+    def has_active_access_code(self) -> Self:
+        """Return all recurring reservations should have an active access code."""
+        return self.filter(L(should_have_active_access_code=True))
path: backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
user: login: ranta
body: Method name is not accurate to its function 🤔 
From the name I would assume that it filters series that HAVE an active access code, but really it filters series that SHOULD HAVE an active access code, but might not necessarily be active 
created_at: 2025-03-17T12:40:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -44,6 +44,10 @@ def requiring_access_code(self) -> Self:
             )
         ).filter(has_missing_access_codes=True)
 
+    def has_active_access_code(self) -> Self:
+        """Return all recurring reservations should have an active access code."""
+        return self.filter(L(should_have_active_access_code=True))
path: backend/tilavarauspalvelu/models/recurring_reservation/queryset.py
user: login: matti-lamppu
body: Changed name.
created_at: 2025-03-19T07:41:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: matti-lamppu
created_at: 2025-03-19T07:46:30Z
body: Merged #1639 and #1637 to this PR to be merged to main together
author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-19T07:56:07Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1636) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [19.9% Duplication on New Code](https://sonarcloud.io/component_measures?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1636&metric=new_duplicated_lines_density&view=list) (required ≤ 3%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=City-of-Helsinki_tilavarauspalvelu-core&pullRequest=1636)


author_association: NONE
user: login: matti-lamppu
created_at: 2025-03-19T07:56:14Z
body: /ff
author_association: CONTRIBUTOR