From d118ed16df060f65c4857e1e9347164975ddc083 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 24 Feb 2024 02:16:24 +0100
Subject: [PATCH] Reformat, add translations and fix gql queries

---
 .../frontend/components/AmendLesson.vue       |  22 +-
 .../components/AmendLessonOverview.vue        | 206 +++++++++---------
 .../frontend/components/AmendedLessonCard.vue |  80 ++++---
 .../frontend/components/LessonInformation.vue |  31 ++-
 .../frontend/components/amendLesson.graphql   |  19 +-
 aleksis/apps/chronos/frontend/index.js        |  36 ++-
 .../apps/chronos/frontend/messages/de.json    |  13 ++
 .../apps/chronos/frontend/messages/en.json    |  13 ++
 aleksis/apps/chronos/managers.py              |  17 +-
 aleksis/apps/chronos/model_extensions.py      |   4 +
 aleksis/apps/chronos/models.py                |  33 ++-
 aleksis/apps/chronos/rules.py                 |  16 +-
 aleksis/apps/chronos/schema/__init__.py       |  76 +++----
 aleksis/apps/chronos/util/predicates.py       |  32 ++-
 14 files changed, 379 insertions(+), 219 deletions(-)

diff --git a/aleksis/apps/chronos/frontend/components/AmendLesson.vue b/aleksis/apps/chronos/frontend/components/AmendLesson.vue
index 587ccdf0..8565ed6c 100644
--- a/aleksis/apps/chronos/frontend/components/AmendLesson.vue
+++ b/aleksis/apps/chronos/frontend/components/AmendLesson.vue
@@ -3,12 +3,12 @@
     <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"
@@ -23,7 +23,7 @@
       :edit-item="initPatchData"
       @cancel="open = false"
       @save="updateOnSave()"
-      >
+    >
       <template #subject.field="{ attrs, on, item }">
         <v-autocomplete
           :disabled="item.cancelled"
@@ -32,7 +32,7 @@
           item-value="id"
           v-bind="attrs"
           v-on="on"
-          />
+        />
       </template>
       <template #teachers.field="{ attrs, on, item }">
         <v-autocomplete
@@ -45,7 +45,7 @@
           v-on="on"
           chips
           deletable-chips
-          />
+        />
       </template>
       <template #rooms.field="{ attrs, on, item }">
         <v-autocomplete
@@ -58,7 +58,7 @@
           v-on="on"
           chips
           deletable-chips
-          />
+        />
       </template>
       <template #cancelled.field="{ attrs, on }">
         <v-checkbox v-bind="attrs" v-on="on" />
@@ -73,7 +73,7 @@
       v-model="deleteEvent"
       :item="selectedEvent.meta"
       @success="updateOnSave()"
-      >
+    >
       <template #title>
         {{ $t("chronos.event.amend.delete_dialog") }}
       </template>
@@ -109,7 +109,7 @@ export default {
     selectedEvent: {
       type: Object,
       required: true,
-    }
+    },
   },
   data() {
     return {
@@ -165,7 +165,7 @@ export default {
       };
     },
     updateOnSave() {
-      this.$emit('refreshCalendar');
+      this.$emit("refreshCalendar");
       this.model = false;
     },
   },
@@ -174,7 +174,9 @@ export default {
       return {
         id: this.selectedEvent.meta.id,
         subject: this.selectedEvent.meta.subject?.id.toString(),
-        teachers: this.selectedEvent.meta.teachers.map((teacher) => teacher.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,
diff --git a/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue b/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue
index 15b32e03..1a9e57bd 100644
--- a/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue
+++ b/aleksis/apps/chronos/frontend/components/AmendLessonOverview.vue
@@ -6,148 +6,136 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
 
 import { DateTime } from "luxon";
 
-import { amendedLessonsFromAbsences, batchPatchAmendLessons, gqlGroups } from "./amendLesson.graphql";
+import {
+  amendedLessonsFromAbsences,
+  batchPatchAmendLessons,
+  groupsByOwner,
+} from "./amendLesson.graphql";
 </script>
 
 <template>
   <c-r-u-d-iterator
     :gql-query="gqlQuery"
+    :gql-additional-query-args="gqlQueryArgs"
     :gql-patch-mutation="gqlPatchMutation"
     :get-patch-data="gqlGetPatchData"
-    :gql-filters="gqlFilters"
-    i18n-key="test"
-    :enable-search="false"
-    :enable-filter="true"
+    i18n-key="chronos.amend_lesson.overview"
+    :enable-search="true"
     :enable-create="false"
     :show-create="false"
     :enable-delete="false"
     :enable-edit="true"
-    :headers="headers"
     :force-model-item-update="true"
     @lastQuery="lastQuery = $event"
   >
+    <template #additionalActions="{ attrs, on }">
+      <v-autocomplete
+        :items="groups"
+        item-text="name"
+        clearable
+        return-object
+        filled
+        dense
+        hide-details
+        :placeholder="$t('chronos.amend_lesson.overview.filter.groups')"
+        :loading="$apollo.queries.groups.loading"
+        :value="currentObj"
+        @input="changeSelection"
+        @click:clear="changeSelection"
+      />
+    </template>
+
     <template #default="{ items }">
       <v-list-item v-for="day in groupAmendedLessonsByDay(items)" two-line>
         <v-list-item-content>
           <v-list-item-title>{{ $d(day[0], "short") }}</v-list-item-title>
           <v-list>
             <v-list-item v-for="amendedLesson in day.slice(1)">
-              <amended-lesson-card :amended-lesson="amendedLesson" :affected-query="lastQuery" :is-create="false" :gql-patch-mutation="batchPatchAmendLessons" />
+              <amended-lesson-card
+                :amended-lesson="amendedLesson"
+                :affected-query="lastQuery"
+                :is-create="false"
+                :gql-patch-mutation="batchPatchAmendLessons"
+              />
             </v-list-item>
           </v-list>
         </v-list-item-content>
       </v-list-item>
     </template>
-
-    <!--<template #groups="{ item }">-->
-    <!--  <lesson-related-object-chip-->
-    <!--    v-for="group in item.realAmends.groups"-->
-    <!--    :key="group.id"-->
-    <!--  >-->
-    <!--    {{ group.shortName }}-->
-    <!--  </lesson-related-object-chip-->
-    <!--  >-->
-    <!--</template>-->
-
-    <!--<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>-->
-    
-    <template #filters="{ attrs, on }">
-      <date-field
-        v-bind="attrs('date_start')"
-        v-on="on('date_start')"
-        :label="$t('start')"
-      />
-
-      <date-field
-        v-bind="attrs('date_end')"
-        v-on="on('date_end')"
-        :label="$t('end')"
-      />
-
-      <v-autocomplete
-        v-bind="attrs('group_id')"
-        v-on="on('group_id')"
-        :label="$t('group')"
-        :items="groups"
-        item-text="shortName"
-        item-value="id"
-      />
-    </template>
   </c-r-u-d-iterator>
 </template>
 
 <script>
-  export default {
-    props: {
+export default {
+  props: {
+    objId: {
+      type: [Number, String],
+      required: false,
+      default: null,
     },
-    data() {
-      return {
-        gqlQuery: amendedLessonsFromAbsences,
-        gqlPatchMutation: batchPatchAmendLessons,
-        gqlFilters: {
-          group_id: 2,
-        },
-        headers: [
-          {
-            text: "date & time start",
-            value: "datetimeStart",
-            disableEdit: true,
-          },
-          {
-            text: "date & time end",
-            value: "datetimeEnd",
-            disableEdit: true,
-          },
-          {
-            text: "subject",
-            value: "subject",
-            disableEdit: true,
-          },
-          {
-            text: "groups",
-            value: "groups",
-            disableEdit: true,
-          },
-          {
-            text: "teachers",
-            value: "teachers",
-            cols: 12,
-          }
-        ],
-        lastQuery: null,
-      };
+    // Next two in ISODate
+    dateStart: {
+      type: String,
+      required: false,
+      default: "",
+    },
+    dateEnd: {
+      type: String,
+      required: false,
+      default: "",
     },
-    methods: {
-      groupAmendedLessonsByDay(amendedLessons) {
-        const byDay = amendedLessons.reduce((byDay, amendedLesson) => {
-          const day = DateTime.fromISO(amendedLesson.datetimeStart).startOf("day");
-          byDay[day] ??= [day];
-          byDay[day].push(amendedLesson);
-          return byDay;
-        }, {});
+  },
+  data() {
+    return {
+      gqlQuery: amendedLessonsFromAbsences,
+      gqlPatchMutation: batchPatchAmendLessons,
+      lastQuery: null,
+      groups: [],
+    };
+  },
+  methods: {
+    groupAmendedLessonsByDay(amendedLessons) {
+      const byDay = amendedLessons.reduce((byDay, amendedLesson) => {
+        const day = DateTime.fromISO(amendedLesson.datetimeStart).startOf(
+          "day",
+        );
+        byDay[day] ??= [day];
+        byDay[day].push(amendedLesson);
+        return byDay;
+      }, {});
 
-        return Object.keys(byDay)
+      return Object.keys(byDay)
         .sort()
         .map((key) => byDay[key]);
-      },
-      gqlGetPatchData(item) {
-        return { id: item.id, teachers: item.teachers }
-      },
     },
-    apollo: {
-      groups: gqlGroups,
-    }
-  };    
+    gqlGetPatchData(item) {
+      return { id: item.id, teachers: item.teachers };
+    },
+    changeSelection(selection) {
+      this.$router.push({
+        name: "chronos.amendLessonOvervievByTypeAndDate",
+        params: {
+          objId: selection.id,
+          dateStart: this.dateStart,
+          dateEnd: this.dateEnd,
+        },
+      });
+    },
+  },
+  computed: {
+    gqlQueryArgs() {
+      return {
+        objId: this.objId ? Number(this.objId) : null,
+        dateStart: this.dateStart,
+        dateEnd: this.dateEnd,
+      };
+    },
+    currentObj() {
+      return this.groups.find((o) => o.id === this.objId);
+    },
+  },
+  apollo: {
+    groups: groupsByOwner,
+  },
+};
 </script>
diff --git a/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue b/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue
index a29a1838..982515a8 100644
--- a/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue
+++ b/aleksis/apps/chronos/frontend/components/AmendedLessonCard.vue
@@ -12,12 +12,14 @@ import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js";
   <v-card class="my-2 full-width">
     <!-- flex-md-row zeile ab medium -->
     <!-- align-stretch - stretch full-width -->
-    <div class="full-width d-flex flex-md-row flex-column align-center justify-space-between">
+    <div
+      class="full-width d-flex flex-md-row flex-column align-center justify-space-between"
+    >
       <lesson-information
         class="flex-grow-1"
         :lesson="$attrs['amended-lesson']"
       />
-      
+
       <v-autocomplete
         v-model="teachers"
         multiple
@@ -30,13 +32,29 @@ import createOrPatchMixin from "aleksis.core/mixins/createOrPatchMixin.js";
         @input="save"
       >
         <template #prepend-inner>
-          <v-chip v-for="teacher in teachersWithStatus($attrs['amended-lesson']).filter((t) => t.status === 'removed')" class="text-decoration-line-through text--secondary mb-2">{{ teacher.fullName }}</v-chip>
+          <v-chip
+            v-for="teacher in teachersWithStatus(
+              $attrs['amended-lesson'],
+            ).filter((t) => t.status === 'removed')"
+            class="text-decoration-line-through text--secondary mb-2"
+            >{{ teacher.fullName }}</v-chip
+          >
         </template>
       </v-autocomplete>
-      
-      <delete-button class="flex-grow-1 mx-2" color="red white--text" @click="toggleCancel">{{ $attrs['amended-lesson'].cancelled ? "de-cancel" : "cancel" }}</delete-button>
+
+      <delete-button
+        class="flex-grow-1 mx-2"
+        color="red white--text"
+        outlined
+        @click="toggleCancel"
+        >{{
+          $attrs["amended-lesson"].cancelled
+            ? $t("chronos.amend_lesson.overview.cancel.decancel")
+            : $t("chronos.amend_lesson.overview.cancel.cancel")
+        }}</delete-button
+      >
     </div>
-    <v-divider/>
+    <v-divider />
     <!--<v-card-actions>-->
     <!--  <v-spacer/>-->
     <!--  <cancel-button @click="$emit('close')" :disabled="loading" />-->
@@ -69,38 +87,46 @@ export default {
     teachersWithStatus(lesson) {
       let oldIds = lesson.realAmends.teachers.map((teacher) => teacher.id);
       let newIds = lesson.teachers.map((teacher) => teacher.id);
-      let teachersWithStatus = lesson.realAmends.teachers.concat(lesson.teachers).map((teacher) => {
-        let status = "regular";
-        if (newIds.includes(teacher.id) && !oldIds.includes(teacher.id)) {
-          status = "new";
-        } else if (
-          !newIds.includes(teacher.id) &&
-          oldIds.includes(teacher.id)
-        ) {
-          status = "removed";
-        }
-        return { ...teacher, status: status };
-      });
+      let teachersWithStatus = lesson.realAmends.teachers
+        .concat(lesson.teachers)
+        .map((teacher) => {
+          let status = "regular";
+          if (newIds.includes(teacher.id) && !oldIds.includes(teacher.id)) {
+            status = "new";
+          } else if (
+            !newIds.includes(teacher.id) &&
+            oldIds.includes(teacher.id)
+          ) {
+            status = "removed";
+          }
+          return { ...teacher, status: status };
+        });
       return teachersWithStatus;
     },
     save() {
-      this.createOrPatch([{
-        id: this.$attrs["amended-lesson"].id,
-        teachers: this.teachers,
-      }]);
+      this.createOrPatch([
+        {
+          id: this.$attrs["amended-lesson"].id,
+          teachers: this.teachers,
+        },
+      ]);
     },
     toggleCancel() {
-      this.createOrPatch([{
-        id: this.$attrs["amended-lesson"].id,
-        cancelled: !this.$attrs["amended-lesson"].cancelled,
-      }]);
+      this.createOrPatch([
+        {
+          id: this.$attrs["amended-lesson"].id,
+          cancelled: !this.$attrs["amended-lesson"].cancelled,
+        },
+      ]);
     },
   },
   apollo: {
     amendableTeachers: gqlPersons,
   },
   mounted() {
-    this.teachers = this.$attrs["amended-lesson"].teachers.map((teacher) => teacher.id);
+    this.teachers = this.$attrs["amended-lesson"].teachers.map(
+      (teacher) => teacher.id,
+    );
   },
 };
 </script>
diff --git a/aleksis/apps/chronos/frontend/components/LessonInformation.vue b/aleksis/apps/chronos/frontend/components/LessonInformation.vue
index a43ab8b9..e89e58af 100644
--- a/aleksis/apps/chronos/frontend/components/LessonInformation.vue
+++ b/aleksis/apps/chronos/frontend/components/LessonInformation.vue
@@ -7,19 +7,18 @@ import { DateTime } from "luxon";
 
 <template>
   <v-card-text>
-    <cancelled-calendar-status-chip
-      v-if="lesson.cancelled"
-      class="mr-2"
-    />
-    <div :class="{ 'text-decoration-line-through': lesson.cancelled, 'text--secondary': lesson.cancelled }">
+    <cancelled-calendar-status-chip v-if="lesson.cancelled" class="mr-2" />
+    <div
+      :class="{
+        'text-decoration-line-through': lesson.cancelled,
+        'text--secondary': lesson.cancelled,
+      }"
+    >
       {{ $d(toDateTime(lesson.datetimeStart), "shortTime") }} –
       {{ $d(toDateTime(lesson.datetimeEnd), "shortTime") }}
       {{ getCourse(lesson)?.name }}
     </div>
-    <subject-chip
-      v-if="getSubject(lesson)"
-      :subject="getSubject(lesson)"
-    />
+    <subject-chip v-if="getSubject(lesson)" :subject="getSubject(lesson)" />
   </v-card-text>
 </template>
 
@@ -42,10 +41,20 @@ export default {
       return DateTime.fromISO(dateString);
     },
     getSubject(lesson) {
-      return lesson.subject ? lesson.subject : lesson.course?.subject ? lesson.course.subject : lesson.realAmends?.subject ? lesson.realAmends.subject : undefined;
+      return lesson.subject
+        ? lesson.subject
+        : lesson.course?.subject
+          ? lesson.course.subject
+          : lesson.realAmends?.subject
+            ? lesson.realAmends.subject
+            : undefined;
     },
     getCourse(lesson) {
-      return lesson.course ? lesson.course : lesson.realAmends?.course ? lesson.realAmends.course : undefined;
+      return lesson.course
+        ? lesson.course
+        : lesson.realAmends?.course
+          ? lesson.realAmends.course
+          : undefined;
     },
   },
 };
diff --git a/aleksis/apps/chronos/frontend/components/amendLesson.graphql b/aleksis/apps/chronos/frontend/components/amendLesson.graphql
index 1868fca4..09312c24 100644
--- a/aleksis/apps/chronos/frontend/components/amendLesson.graphql
+++ b/aleksis/apps/chronos/frontend/components/amendLesson.graphql
@@ -27,6 +27,13 @@ query gqlGroups {
   }
 }
 
+query groupsByOwner {
+  groups: groupsByOwner {
+    id
+    name
+  }
+}
+
 mutation createAmendLesson($input: CreateLessonEventInput!) {
   createAmendLesson(input: $input) {
     lessonEvent {
@@ -145,8 +152,16 @@ mutation deleteAmendLesson($id: ID!) {
   }
 }
 
-query amendedLessonsFromAbsences($filters: JSONString!) {
-  items: amendedLessonsFromAbsences(filters: $filters) {
+query amendedLessonsFromAbsences(
+  $objId: ID
+  $dateStart: Date!
+  $dateEnd: Date!
+) {
+  items: amendedLessonsFromAbsences(
+    objId: $objId
+    dateStart: $dateStart
+    dateEnd: $dateEnd
+  ) {
     id
     subject {
       id
diff --git a/aleksis/apps/chronos/frontend/index.js b/aleksis/apps/chronos/frontend/index.js
index 494a0e90..9bcdf971 100644
--- a/aleksis/apps/chronos/frontend/index.js
+++ b/aleksis/apps/chronos/frontend/index.js
@@ -1,6 +1,8 @@
 import { hasPersonValidator } from "aleksis.core/routeValidators";
 import Timetable from "./components/Timetable.vue";
-import AmendLessonOverview from "./components/AmendLessonOverview.vue"
+import AmendLessonOverview from "./components/AmendLessonOverview.vue";
+
+import { DateTime } from "luxon";
 
 export default {
   meta: {
@@ -34,13 +36,39 @@ export default {
     },
     {
       path: "amend_lesson_overview/",
-      component: AmendLessonOverview,
+      component: () => import("./components/AmendLessonOverview.vue"),
+      redirect: (to) => {
+        return {
+          name: "chronos.amendLessonOvervievByTypeAndDate",
+          params: {
+            dateStart: DateTime.now().toISODate(),
+            dateEnd: DateTime.now().plus({ weeks: 1 }).toISODate(),
+          },
+        };
+      },
       name: "chronos.amendLessonOverview",
+      props: true,
       meta: {
         inMenu: true,
-        titleKey: "chronos.amendLessonOverview.menu_title",
-        icon: "mdi-grid",
+        icon: "mdi-account-convert-outline",
+        iconActive: "mdi-account-convert",
+        titleKey: "chronos.amend_lesson.overview.menu_title",
+        toolbarTitle: "chronos.amend_lesson.overview.menu_title",
+        permission: "chronos.view_substitution_overview_rule",
       },
+      children: [
+        {
+          path: ":dateStart(\\d\\d\\d\\d-\\d\\d-\\d\\d)/:dateEnd(\\d\\d\\d\\d-\\d\\d-\\d\\d)/:objId(\\d+)?/",
+          component: () => import("./components/AmendLessonOverview.vue"),
+          name: "chronos.amendLessonOvervievByTypeAndDate",
+          meta: {
+            titleKey: "chronos.amend_lesson.overview.menu_title",
+            toolbarTitle: "chronos.amend_lesson.overview.menu_title",
+            permission: "chronos.view_substitution_overview_rule",
+            fullWidth: true,
+          },
+        },
+      ],
     },
   ],
 };
diff --git a/aleksis/apps/chronos/frontend/messages/de.json b/aleksis/apps/chronos/frontend/messages/de.json
index 86148a7b..cd6011a3 100644
--- a/aleksis/apps/chronos/frontend/messages/de.json
+++ b/aleksis/apps/chronos/frontend/messages/de.json
@@ -32,6 +32,19 @@
       "no_teacher": "Keine Lehrkraft",
       "no_room": "Kein Raum",
       "current_changes": "Aktuelle Änderungen"
+    },
+    "amend_lesson": {
+      "overview": {
+        "menu_title": "Vertretungsstunden planen",
+        "title_plural": "Vertretungsstunden planen",
+        "filter": {
+          "groups": "Nach Gruppen filtern"
+        },
+        "cancel": {
+          "cancel": "Stunde ausfallen lassen",
+          "decancel": "Stunde nicht ausfallen lassen"
+        }
+      }
     }
   }
 }
diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json
index ee23c28f..0b5c7fa8 100644
--- a/aleksis/apps/chronos/frontend/messages/en.json
+++ b/aleksis/apps/chronos/frontend/messages/en.json
@@ -44,6 +44,19 @@
         "cancelled": "Cancelled",
         "comment": "Comment"
       }
+    },
+    "amend_lesson": {
+      "overview": {
+        "menu_title": "Amend lessons",
+        "title_plural": "Amend lessons",
+        "filter": {
+          "groups": "Filter by groups"
+        },
+        "cancel": {
+          "cancel": "Mark lesson as cancelled",
+          "decancel": "Mark lesson as not cancelled"
+        }
+      }
     }
   }
 }
diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py
index 12c52170..391ffb5c 100644
--- a/aleksis/apps/chronos/managers.py
+++ b/aleksis/apps/chronos/managers.py
@@ -905,17 +905,22 @@ class LessonEventQuerySet(PolymorphicQuerySet):
 
     def affected_by_absences(self, datetime_start: datetime, datetime_end: datetime):
         return self.filter(
-            ((Q(teachers__kolego_absences__datetime_start__gte=datetime_start)
-              & Q(teachers__kolego_absences__datetime_start__lte=datetime_end))
-             | (Q(teachers__kolego_absences__datetime_end__gte=datetime_start)
-                & Q(teachers__kolego_absences__datetime_end__lte=datetime_end))
-             )
+            (
+                (
+                    Q(teachers__kolego_absences__datetime_start__gte=datetime_start)
+                    & Q(teachers__kolego_absences__datetime_start__lte=datetime_end)
+                )
+                | (
+                    Q(teachers__kolego_absences__datetime_end__gte=datetime_start)
+                    & Q(teachers__kolego_absences__datetime_end__lte=datetime_end)
+                )
+            )
             & Q(teachers__kolego_absences__datetime_start__lte=F("datetime_end"))
             & Q(teachers__kolego_absences__datetime_end__gte=F("datetime_start"))
             & Q(amends__isnull=True)
             & Q(amended_by__isnull=True)
         )
-    
+
     def related_to_person(self, person: Union[int, Person]):
         amended = self.filter(
             Q(amended_by__isnull=False)
diff --git a/aleksis/apps/chronos/model_extensions.py b/aleksis/apps/chronos/model_extensions.py
index 8f43350e..ff8e745d 100644
--- a/aleksis/apps/chronos/model_extensions.py
+++ b/aleksis/apps/chronos/model_extensions.py
@@ -144,6 +144,10 @@ Group.add_permission(
     "view_group_timetable",
     _("Can view group timetable"),
 )
+Group.add_permission(
+    "manage_group_substitutions",
+    _("Can manage group substitutions"),
+)
 Person.add_permission(
     "view_person_timetable",
     _("Can view person timetable"),
diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py
index 0ee5cfc9..09353bbc 100644
--- a/aleksis/apps/chronos/models.py
+++ b/aleksis/apps/chronos/models.py
@@ -5,7 +5,7 @@ import itertools
 from collections.abc import Iterable, Iterator
 from datetime import date, datetime, time, timedelta
 from itertools import chain
-from typing import Any
+from typing import Any, Optional
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import PermissionDenied, ValidationError
@@ -1581,15 +1581,36 @@ class LessonEvent(CalendarEvent):
         return objs.for_person(request.user.person)
 
     @classmethod
-    def get_for_substitution_overview(cls, obj_type: str, obj_id: str, date_start: datetime, date_end: datetime, request: HttpRequest) -> list:
+    def get_for_substitution_overview(
+        cls,
+        date_start: datetime,
+        date_end: datetime,
+        request: HttpRequest,
+        obj_type: Optional[str],
+        obj_id: Optional[str],
+    ) -> list:
         """Get all the amended lessons for an object and a time frame.
 
         obj_type may be one of TEACHER, GROUP, ROOM, COURSE
         """
 
         # 1. Find all LessonEvents for all Lessons of this Group in this date range and which are not themselves amending another lessonEvent
+        event_params = {
+            "own": False,
+            "not_amending": True,
+            "prefetch_absences": True,
+        }
+        if obj_type is not None and obj_id is not None:
+            event_params.update(
+                {
+                    "type": obj_type,
+                    "id": obj_id,
+                }
+            )
 
-        events = LessonEvent.get_single_events(date_start, date_end, request, {"type": obj_type, "id": obj_id, "not_amending": True, "prefetch_absences": True}, with_reference_object=True)
+        events = LessonEvent.get_single_events(
+            date_start, date_end, request, event_params, with_reference_object=True
+        )
         # (1.5 filter them by permissions)
         ...
 
@@ -1601,8 +1622,10 @@ class LessonEvent(CalendarEvent):
         for event in events:
             reference_obj = event["REFERENCE_OBJECT"]
 
-            affected_teachers = reference_obj.teachers.filter(Q(kolego_absences__datetime_start__lte=event["DTEND"].dt)
-                                                              & Q(kolego_absences__datetime_end__gte=event["DTSTART"].dt))
+            affected_teachers = reference_obj.teachers.filter(
+                Q(kolego_absences__datetime_start__lte=event["DTEND"].dt)
+                & Q(kolego_absences__datetime_end__gte=event["DTSTART"].dt)
+            )
 
             if affected_teachers.exists():
                 obj, created = LessonEvent.objects.update_or_create(
diff --git a/aleksis/apps/chronos/rules.py b/aleksis/apps/chronos/rules.py
index 85a0d1c4..03ef88c2 100644
--- a/aleksis/apps/chronos/rules.py
+++ b/aleksis/apps/chronos/rules.py
@@ -8,7 +8,13 @@ from aleksis.core.util.predicates import (
 )
 
 from .models import LessonSubstitution
-from .util.predicates import has_any_timetable_object, has_room_timetable_perm, has_timetable_perm
+from .util.predicates import (
+    has_any_group_substitution_perm,
+    has_any_timetable_object,
+    has_group_substitution_perm,
+    has_room_timetable_perm,
+    has_timetable_perm,
+)
 
 # View timetable overview
 view_timetable_overview_predicate = has_person & (
@@ -27,6 +33,14 @@ add_perm("chronos.view_timetable_rule", view_timetable_predicate)
 view_lessons_day_predicate = has_person & has_global_perm("chronos.view_lessons_day")
 add_perm("chronos.view_lessons_day_rule", view_lessons_day_predicate)
 
+# View substitution management overview page
+view_substitution_overview_predicate = has_person & has_any_group_substitution_perm
+add_perm("chronos.view_substitution_overview_rule", view_substitution_overview_predicate)
+
+# Manage substitutions for a group
+manage_substitutions_for_group_predicate = has_person & has_group_substitution_perm
+add_perm("chronos.manage_substitutions_for_group_rule", manage_substitutions_for_group_predicate)
+
 # Edit substition
 edit_substitution_predicate = has_person & (
     has_global_perm("chronos.change_lessonsubstitution")
diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py
index d4251761..76963150 100644
--- a/aleksis/apps/chronos/schema/__init__.py
+++ b/aleksis/apps/chronos/schema/__init__.py
@@ -2,14 +2,19 @@ from datetime import date, datetime, timezone
 
 from functools import reduce
 from operator import and_
+from django.core.exceptions import PermissionDenied
 from django.db.models import F, ManyToManyField, OuterRef, Subquery, Q, Prefetch
 
 import graphene
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import DjangoBatchPatchMutation, DjangoCreateMutation, DjangoPatchMutation
+from graphene_django_cud.mutations import (
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+    DjangoPatchMutation,
+)
 
 from aleksis.core.models import CalendarEvent, Group, Person, Room
-from aleksis.core.schema.base import DeleteMutation
+from aleksis.core.schema.base import DeleteMutation, FilterOrderList
 
 from aleksis.apps.kolego.models import Absence
 
@@ -63,26 +68,11 @@ class LessonEventType(DjangoObjectType):
             "cancelled",
             "comment",
         )
+        filter_fields = {
+            "id": ["exact", "lte", "gte"],
+        }
 
-
-class LessonEventTypeWithRealAmends(DjangoObjectType):
-    class Meta:
-        model = LessonEvent
-        fields = (
-            "id",
-            "amends",
-            "datetime_start",
-            "datetime_end",
-            "subject",
-            "teachers",
-            "groups",
-            "rooms",
-            "course",
-            "cancelled",
-            "comment",
-        )
-
-    real_amends = graphene.Field(LessonEventType, required=False)
+    real_amends = graphene.Field(lambda: LessonEventType, required=False)
 
 
 class DatetimeTimezoneMixin:
@@ -112,7 +102,7 @@ class DatetimeTimezoneMixin:
 
         if patch_obj:
             obj = patch_obj
-            
+
         obj.timezone = obj.amends.timezone
         return obj
 
@@ -173,24 +163,18 @@ 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)
     timetable_rooms = graphene.List(TimetableRoomType)
     available_timetables = graphene.List(TimetableObjectType)
 
-    amended_lessons_from_absences = graphene.List(
-        LessonEventTypeWithRealAmends,
-        filters=graphene.JSONString(required=True),
+    amended_lessons_from_absences = FilterOrderList(
+        LessonEventType,
+        obj_type=graphene.String(required=False),
+        obj_id=graphene.ID(required=False),
+        date_start=graphene.Date(required=True),
+        date_end=graphene.Date(required=True),
     )
 
     def resolve_timetable_teachers(self, info, **kwargs):
@@ -234,17 +218,23 @@ class Query(graphene.ObjectType):
 
         return all_timetables
 
-    def resolve_amended_lessons_from_absences(root, info, filters, **kwargs):
-        if isinstance(filters, str):
-            filters = json.loads(filters)
-
-        datetime_start = datetime.combine(date.fromisoformat(filters.get("date_start", datetime.now().date().isoformat())) , datetime.min.time())
-        datetime_end = datetime.combine(date.fromisoformat(filters.get("date_end", datetime.now().date().isoformat())), datetime.max.time())
+    def resolve_amended_lessons_from_absences(
+        root, info, date_start, date_end, obj_type="GROUP", obj_id=None, **kwargs
+    ):
+        datetime_start = datetime.combine(date_start, datetime.min.time())
+        datetime_end = datetime.combine(date_end, datetime.max.time())
 
-        group_id = filters.get("group_id")
+        if (
+            obj_id
+            and not info.context.user.has_perm(
+                "chronos.manage_substitutions_for_group_rule", Group.objects.get(id=obj_id)
+            )
+        ) or (not obj_id and not info.context.user.has_perm("chronos.view_substitution_overview_rule")):
+            raise PermissionDenied()
 
-        # TODO: later on, allow getting amended lessons for other types than courses, e.g. groups or persons
-        return LessonEvent.get_for_substitution_overview("GROUP", group_id, datetime_start, datetime_end, info.context)
+        return LessonEvent.get_for_substitution_overview(
+            datetime_start, datetime_end, info.context, obj_type, obj_id
+        )
 
 
 class Mutation(graphene.ObjectType):
diff --git a/aleksis/apps/chronos/util/predicates.py b/aleksis/apps/chronos/util/predicates.py
index d99ac20a..bef69857 100644
--- a/aleksis/apps/chronos/util/predicates.py
+++ b/aleksis/apps/chronos/util/predicates.py
@@ -4,7 +4,7 @@ from django.db.models import Model
 from rules import predicate
 
 from aleksis.core.models import Group, Person, Room
-from aleksis.core.util.predicates import has_global_perm, has_object_perm
+from aleksis.core.util.predicates import has_any_object, has_global_perm, has_object_perm
 
 from .chronos_helpers import get_classes, get_rooms, get_teachers
 
@@ -44,6 +44,36 @@ def has_group_timetable_perm(user: User, obj: Group) -> bool:
     )
 
 
+@predicate
+def has_group_substitution_perm(user: User, obj: Group) -> bool:
+    """
+    Check if can access/edit group substitutions.
+
+    Predicate which checks whether the user is allowed
+    to access/edit the requested group substitutions.
+    """
+    return (
+        obj in user.person.owner_of.all()
+        or has_global_perm("chronos.view_lessonsubstitution")(user)
+        or has_object_perm("core.manage_group_substitutions")(user, obj)
+    )
+
+
+@predicate
+def has_any_group_substitution_perm(user: User) -> bool:
+    """
+    Check if can access/edit any group substitutions.
+
+    Predicate which checks whether the user is allowed
+    to access/edit any group substitutions.
+    """
+    return (
+        user.person.owner_of.exists()
+        or has_global_perm("chronos.view_lessonsubstitution")(user)
+        or has_any_object("core.manage_group_substitutions")(user, Group)
+    )
+
+
 @predicate
 def has_person_timetable_perm(user: User, obj: Person) -> bool:
     """
-- 
GitLab