diff --git a/aleksis/apps/chronos/frontend/components/AmendLesson.vue b/aleksis/apps/chronos/frontend/components/AmendLesson.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2d9f8b4c9d2798ba0afcedeb4711debbfa1ae0fc
--- /dev/null
+++ b/aleksis/apps/chronos/frontend/components/AmendLesson.vue
@@ -0,0 +1,207 @@
+<template>
+  <v-card-actions v-if="checkPermission('chronos.edit_substitution_rule')">
+    <edit-button
+      i18n-key="chronos.event.amend.edit_button"
+      @click="edit = true"
+    />
+    <delete-button
+      v-if="selectedEvent.meta.amended"
+      i18n-key="chronos.event.amend.delete_button"
+      @click="deleteEvent = true"
+    />
+    <dialog-object-form
+      v-model="edit"
+      :fields="fields"
+      :is-create="!selectedEvent.meta.amended"
+      create-item-i18n-key="chronos.event.amend.title"
+      :gql-create-mutation="gqlCreateMutation"
+      :get-create-data="transformCreateData"
+      :default-item="defaultItem"
+      edit-item-i18n-key="chronos.event.amend.title"
+      :gql-patch-mutation="gqlPatchMutation"
+      :get-patch-data="transformPatchData"
+      :edit-item="initPatchData"
+      @cancel="open = false"
+      @save="updateOnSave()"
+    >
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #subject.field="{ attrs, on, item }">
+        <v-autocomplete
+          :disabled="item.cancelled"
+          :items="amendableSubjects"
+          item-text="name"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+        />
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #teachers.field="{ attrs, on, item }">
+        <v-autocomplete
+          :disabled="item.cancelled"
+          multiple
+          :items="amendableTeachers"
+          item-text="fullName"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+          chips
+          deletable-chips
+        />
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #rooms.field="{ attrs, on, item }">
+        <v-autocomplete
+          :disabled="item.cancelled"
+          multiple
+          :items="amendableRooms"
+          item-text="name"
+          item-value="id"
+          v-bind="attrs"
+          v-on="on"
+          chips
+          deletable-chips
+        />
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #cancelled.field="{ attrs, on }">
+        <v-checkbox v-bind="attrs" v-on="on" />
+      </template>
+      <!-- eslint-disable-next-line vue/valid-v-slot -->
+      <template #comment.field="{ attrs, on }">
+        <v-textarea v-bind="attrs" v-on="on" />
+      </template>
+    </dialog-object-form>
+    <delete-dialog
+      delete-success-message-i18n-key="chronos.event.amend.delete_success"
+      :gql-delete-mutation="gqlDeleteMutation"
+      v-model="deleteEvent"
+      :items="[selectedEvent.meta]"
+      :get-name-of-item="getLessonDeleteText"
+      @save="updateOnSave()"
+    >
+      <template #title>
+        {{ $t("chronos.event.amend.delete_dialog") }}
+      </template>
+    </delete-dialog>
+  </v-card-actions>
+</template>
+
+<script>
+import permissionsMixin from "aleksis.core/mixins/permissions.js";
+import EditButton from "aleksis.core/components/generic/buttons/EditButton.vue";
+import DialogObjectForm from "aleksis.core/components/generic/dialogs/DialogObjectForm.vue";
+import DeleteButton from "aleksis.core/components/generic/buttons/DeleteButton.vue";
+import DeleteDialog from "aleksis.core/components/generic/dialogs/DeleteDialog.vue";
+import {
+  gqlSubjects,
+  gqlPersons,
+  gqlRooms,
+  createAmendLessons,
+  patchAmendLessons,
+  deleteAmendLessons,
+} from "./amendLesson.graphql";
+
+export default {
+  name: "AmendLesson",
+  components: {
+    EditButton,
+    DialogObjectForm,
+    DeleteButton,
+    DeleteDialog,
+  },
+  mixins: [permissionsMixin],
+  props: {
+    selectedEvent: {
+      type: Object,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      edit: false,
+      fields: [
+        {
+          text: this.$t("chronos.event.amend.subject"),
+          value: "subject",
+        },
+        {
+          text: this.$t("chronos.event.amend.teachers"),
+          value: "teachers",
+        },
+        {
+          text: this.$t("chronos.event.amend.rooms"),
+          value: "rooms",
+        },
+        {
+          text: this.$t("chronos.event.amend.cancelled"),
+          value: "cancelled",
+        },
+        {
+          text: this.$t("chronos.event.amend.comment"),
+          value: "comment",
+        },
+      ],
+      defaultItem: {
+        cancelled: this.selectedEvent.meta.cancelled,
+        comment: this.selectedEvent.meta.comment,
+      },
+      gqlCreateMutation: createAmendLessons,
+      gqlPatchMutation: patchAmendLessons,
+      deleteEvent: false,
+      gqlDeleteMutation: deleteAmendLessons,
+    };
+  },
+  methods: {
+    transformCreateData(item) {
+      return {
+        ...item,
+        amends: this.selectedEvent.meta.id,
+        datetimeStart: this.selectedEvent.startDateTime.toUTC().toISO(),
+        datetimeEnd: this.selectedEvent.endDateTime.toUTC().toISO(),
+      };
+    },
+    transformPatchData(item) {
+      let { id, __typename, cancelled, ...patchItem } = item;
+      return {
+        ...patchItem,
+        // Normalize cancelled, v-checkbox returns null & does not
+        // honor false-value.
+        cancelled: cancelled ? true : false,
+      };
+    },
+    updateOnSave() {
+      this.$emit("refreshCalendar");
+      this.model = false;
+    },
+    getLessonDeleteText(item) {
+      return `${this.selectedEvent.name} · ${this.$d(
+        this.selectedEvent.start,
+        "shortDateTime",
+      )} – ${this.$d(this.selectedEvent.end, "shortTime")}`;
+    },
+  },
+  computed: {
+    initPatchData() {
+      return {
+        id: this.selectedEvent.meta.id,
+        subject: this.selectedEvent.meta.subject?.id.toString(),
+        teachers: this.selectedEvent.meta.teachers.map((teacher) =>
+          teacher.id.toString(),
+        ),
+        rooms: this.selectedEvent.meta.rooms.map((room) => room.id.toString()),
+        cancelled: this.selectedEvent.meta.cancelled,
+        comment: this.selectedEvent.meta.comment,
+      };
+    },
+  },
+  apollo: {
+    amendableSubjects: gqlSubjects,
+    amendableTeachers: gqlPersons,
+    amendableRooms: gqlRooms,
+  },
+  mounted() {
+    this.addPermissions(["chronos.edit_substitution_rule"]);
+  },
+};
+</script>
diff --git a/aleksis/apps/chronos/frontend/components/amendLesson.graphql b/aleksis/apps/chronos/frontend/components/amendLesson.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..5142b0b84c694333edf8ffce8b905fc4e622b1f3
--- /dev/null
+++ b/aleksis/apps/chronos/frontend/components/amendLesson.graphql
@@ -0,0 +1,75 @@
+query gqlSubjects {
+  amendableSubjects: subjects {
+    id
+    name
+  }
+}
+
+query gqlPersons {
+  amendableTeachers: persons {
+    id
+    fullName
+  }
+}
+
+query gqlRooms {
+  amendableRooms: rooms {
+    id
+    name
+  }
+}
+
+mutation createAmendLessons($input: [BatchCreateLessonEventInput]!) {
+  createAmendLessons(input: $input) {
+    items: lessonEvents {
+      id
+      amends {
+        id
+      }
+      datetimeStart
+      datetimeEnd
+      subject {
+        id
+      }
+      teachers {
+        id
+      }
+      groups {
+        id
+      }
+      rooms {
+        id
+      }
+      cancelled
+      comment
+    }
+  }
+}
+
+mutation patchAmendLessons($input: [BatchPatchLessonEventInput]!) {
+  patchAmendLessons(input: $input) {
+    items: lessonEvents {
+      id
+      subject {
+        id
+      }
+      teachers {
+        id
+      }
+      groups {
+        id
+      }
+      rooms {
+        id
+      }
+      cancelled
+      comment
+    }
+  }
+}
+
+mutation deleteAmendLessons($ids: [ID]!) {
+  deleteAmendLessons(ids: $ids) {
+    deletionCount
+  }
+}
diff --git a/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue
index cd91c0cd15cc77b01fd735a6b75b22043209bb2b..627dd2fe2e8395da29d8d6c4162e29b99b2f1d1e 100644
--- a/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue
+++ b/aleksis/apps/chronos/frontend/components/calendar_feeds/details/LessonDetails.vue
@@ -100,6 +100,11 @@
           </v-list-item-title>
         </v-list-item-content>
       </v-list-item>
+      <amend-lesson
+        v-if="selectedEvent"
+        :selected-event="selectedEvent"
+        @refreshCalendar="$emit('refreshCalendar')"
+      />
     </template>
   </base-calendar-feed-details>
 </template>
@@ -111,8 +116,12 @@ import CalendarStatusChip from "aleksis.core/components/calendar/CalendarStatusC
 import CancelledCalendarStatusChip from "aleksis.core/components/calendar/CancelledCalendarStatusChip.vue";
 
 import LessonRelatedObjectChip from "../../LessonRelatedObjectChip.vue";
+
 import lessonEvent from "../mixins/lessonEvent";
 import LessonEventSubject from "../../LessonEventSubject.vue";
+
+import AmendLesson from "../../AmendLesson.vue";
+
 export default {
   name: "LessonDetails",
   components: {
@@ -121,6 +130,7 @@ export default {
     BaseCalendarFeedDetails,
     CalendarStatusChip,
     CancelledCalendarStatusChip,
+    AmendLesson,
   },
   mixins: [calendarFeedDetailsMixin, lessonEvent],
 };
diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json
index 32b0b58d39c05d6366b33677a1d084a1b09f013f..e38a4e03df2f494032be278d85d1c4fcec880984 100644
--- a/aleksis/apps/chronos/frontend/messages/en.json
+++ b/aleksis/apps/chronos/frontend/messages/en.json
@@ -32,7 +32,19 @@
     "event": {
       "no_teacher": "No teacher",
       "no_room": "No room",
-      "current_changes": "Current changes"
+      "current_changes": "Current changes",
+      "amend": {
+        "edit_button": "Change",
+        "delete_button": "Reset",
+        "delete_dialog": "Are you sure you want to delete this substitution?",
+        "delete_success": "The substitution was deleted successfully.",
+        "title": "Change lesson",
+        "subject": "Subject",
+        "teachers": "Teachers",
+        "rooms": "Rooms",
+        "cancelled": "Cancelled",
+        "comment": "Comment"
+      }
     }
   }
 }
diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py
index bf4b3f86078615e0475c27209991dbe06ad10d0a..7f01d7f67c9bff1458804e93a538daa340e014b3 100644
--- a/aleksis/apps/chronos/models.py
+++ b/aleksis/apps/chronos/models.py
@@ -1437,7 +1437,7 @@ class LessonEvent(CalendarEvent):
     def subject_name_with_amends(self: LessonEvent) -> str:
         """Get formatted subject name (including amends)."""
         my_subject = self.subject.name if self.subject else ""
-        amended_subject = self.amends.subject.name if self.amends else ""
+        amended_subject = self.amends.subject.name if self.amends and self.amends.subject else ""
 
         if my_subject and amended_subject:
             return _("{} (instead of {})").format(my_subject, amended_subject)
@@ -1461,8 +1461,10 @@ class LessonEvent(CalendarEvent):
             elif request:
                 title += " · " + reference_object.teacher_names_with_amends
             else:
-                title += f" · {reference_object.group_names} · {reference_object.teacher_names}"
-
+                title += (
+                    f" · {reference_object.group_names} · "
+                    + f"{reference_object.teacher_names_with_amends}"
+                )
             if reference_object.rooms.all().exists():
                 title += " · " + reference_object.room_names_with_amends
             return title
@@ -1522,6 +1524,7 @@ class LessonEvent(CalendarEvent):
         """
 
         return {
+            "id": reference_object.id,
             "amended": bool(reference_object.amends),
             "amends": cls.value_meta(reference_object.amends, request)
             if reference_object.amends
diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py
index b746fc21e8344af3367d53d5d0d7abfff79cd708..a0682ef5e5fa98ee2c36be2cd33801afa4732dda 100644
--- a/aleksis/apps/chronos/schema/__init__.py
+++ b/aleksis/apps/chronos/schema/__init__.py
@@ -1,5 +1,12 @@
+from datetime import timezone
+
 import graphene
 from graphene_django import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchCreateMutation,
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+)
 
 from aleksis.core.models import Group, Person, Room
 
@@ -28,6 +35,90 @@ class TimetableRoomType(DjangoObjectType):
         skip_registry = True
 
 
+class LessonEventType(DjangoObjectType):
+    class Meta:
+        model = LessonEvent
+        fields = (
+            "id",
+            "amends",
+            "datetime_start",
+            "datetime_end",
+            "subject",
+            "teachers",
+            "groups",
+            "rooms",
+            "cancelled",
+            "comment",
+        )
+        filter_fields = {
+            "id": ["exact", "lte", "gte"],
+        }
+
+    amends = graphene.Field(lambda: LessonEventType, required=False)
+
+
+class DatetimeTimezoneMixin:
+    """Handle datetimes for mutations with CalendarEvent objects.
+
+    This is necessary because the client sends timezone information as
+    ISO string which only includes an offset (+00:00 UTC) and an
+    offset is not a valid timezone. Instead we set UTC as timezone
+    here directly.
+    """
+
+    @classmethod
+    def handle_datetime_start(cls, value, name, info) -> int:
+        value = value.replace(tzinfo=timezone.utc)
+        return value
+
+    @classmethod
+    def handle_datetime_end(cls, value, name, info) -> int:
+        value = value.replace(tzinfo=timezone.utc)
+        return value
+
+
+class AmendLessonBatchCreateMutation(DatetimeTimezoneMixin, DjangoBatchCreateMutation):
+    class Meta:
+        model = LessonEvent
+        permissions = ("chronos.edit_substitution_rule",)
+        only_fields = (
+            "amends",
+            "datetime_start",
+            "datetime_end",
+            "subject",
+            "teachers",
+            "groups",
+            "rooms",
+            "cancelled",
+            "comment",
+        )
+
+    @classmethod
+    def before_save(cls, root, info, input, created_objects):  # noqa: A002
+        for obj in created_objects:
+            obj.timezone = obj.amends.timezone
+        return created_objects
+
+
+class AmendLessonBatchPatchMutation(DatetimeTimezoneMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = LessonEvent
+        permissions = ("chronos.edit_substitution_rule",)
+        only_fields = ("subject", "teachers", "groups", "rooms", "cancelled", "comment")
+
+    @classmethod
+    def before_save(cls, root, info, input, updated_objects):  # noqa: A002
+        for obj in updated_objects:
+            obj.timezone = obj.amends.timezone
+        return updated_objects
+
+
+class AmendLessonBatchDeleteMutation(DjangoBatchDeleteMutation):
+    class Meta:
+        model = LessonEvent
+        permissions = ("chronos.delete_substitution_rule",)
+
+
 class TimetableType(graphene.Enum):
     TEACHER = "teacher"
     GROUP = "group"
@@ -48,15 +139,6 @@ class TimetableObjectType(graphene.ObjectType):
         return f"{root.type.value}-{root.id}"
 
 
-class LessonEventType(DjangoObjectType):
-    class Meta:
-        model = LessonEvent
-        fields = ("id",)
-        filter_fields = {
-            "id": ["exact", "lte", "gte"],
-        }
-
-
 class Query(graphene.ObjectType):
     timetable_teachers = graphene.List(TimetablePersonType)
     timetable_groups = graphene.List(TimetableGroupType)
@@ -103,3 +185,9 @@ class Query(graphene.ObjectType):
             )
 
         return all_timetables
+
+
+class Mutation(graphene.ObjectType):
+    create_amend_lessons = AmendLessonBatchCreateMutation.Field()
+    patch_amend_lessons = AmendLessonBatchPatchMutation.Field()
+    delete_amend_lessons = AmendLessonBatchDeleteMutation.Field()
diff --git a/aleksis/apps/chronos/templates/chronos/partials/subs/room.html b/aleksis/apps/chronos/templates/chronos/partials/subs/room.html
index a80afb98487308b20733e1f23cee8a29821b1eb0..94f2d3574992d23dffe3d6f9b03ffdd3e75cb042 100644
--- a/aleksis/apps/chronos/templates/chronos/partials/subs/room.html
+++ b/aleksis/apps/chronos/templates/chronos/partials/subs/room.html
@@ -1,6 +1,6 @@
 {% if type == "substitution" %}
   {% if el.cancelled or el.cancelled_for_teachers %}
-    {# Canceled lesson: no room #}
+    {# Cancelled lesson: no room #}
   {% elif el.room and el.lesson_period.room %}
     {# New and old room available #}
     <span class="tooltipped" data-position="bottom"