diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue
index 14411e9ce9c1c2780402890a80aa907b246262ff..2f15c3fd031c62ff9fc96cf5c35042f2274d3321 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookDay.vue
@@ -1,13 +1,14 @@
 <template>
-  <v-list-item :style="{ scrollMarginTop: '145px' }" two-line>
+  <v-list-item :style="{ scrollMarginTop: '145px' }" two-line class="px-0">
     <v-list-item-content>
-      <v-subheader class="text-h6">{{
+      <v-subheader class="text-h6 px-1">{{
         $d(date, "dateWithWeekday")
       }}</v-subheader>
       <v-list max-width="100%" class="pt-0 mt-n1">
         <v-list-item
           v-for="doc in docs"
           :key="'documentation-' + (doc.oldId || doc.id)"
+          class="px-1"
         >
           <documentation-modal
             :documentation="doc"
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue
index e5493b07cda406fddbb3fccc89e5d1b42080ab89..b47ebbfbb1d1cd8219686f05085eb0eeb8f453d3 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookFilters.vue
@@ -45,6 +45,10 @@
 <script>
 import { coursesOfPerson, groupsByPerson } from "./coursebook.graphql";
 
+const TYPENAMES_TO_TYPES = {
+  CourseType: "course",
+  GroupType: "group",
+};
 export default {
   name: "CoursebookFilters",
   data() {
@@ -73,9 +77,9 @@ export default {
     selectable() {
       return [
         { header: this.$t("alsijil.coursebook.filter.groups") },
-        ...this.groups.map((group) => ({ type: "group", ...group })),
+        ...this.groups,
         { header: this.$t("alsijil.coursebook.filter.courses") },
-        ...this.courses.map((course) => ({ type: "course", ...course })),
+        ...this.courses,
       ];
     },
     selectLoading() {
@@ -86,14 +90,16 @@ export default {
     },
     currentObj() {
       return this.selectable.find(
-        (o) => o.type === this.value.objType && o.id === this.value.objId,
+        (o) =>
+          TYPENAMES_TO_TYPES[o.__typename] === this.value.objType &&
+          o.id === this.value.objId,
       );
     },
   },
   methods: {
     selectObject(selection) {
       this.$emit("input", {
-        objType: selection ? selection.type : null,
+        objType: selection ? TYPENAMES_TO_TYPES[selection.__typename] : null,
         objId: selection ? selection.id : null,
       });
     },
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue
index a5136eb9f0c2535009fd78a02e7481aebacd12b4..52866931e7b2d31bbee85bc754a4a668066e8b73 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/CoursebookLoader.vue
@@ -1,12 +1,12 @@
 <template>
   <div>
-    <v-list-item v-for="i in numberOfDays" :key="'i-' + i">
+    <v-list-item v-for="i in numberOfDays" :key="'i-' + i" class="px-0">
       <v-list-item-content>
         <v-list-item-title>
           <v-skeleton-loader type="heading" />
         </v-list-item-title>
         <v-list max-width="100%">
-          <v-list-item v-for="j in numberOfDocs" :key="'j-' + j">
+          <v-list-item v-for="j in numberOfDocs" :key="'j-' + j" class="px-1">
             <DocumentationLoader />
           </v-list-item>
         </v-list>
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..3af1db58846f37b5e7e7837dba08a4468294269e
--- /dev/null
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsDialog.vue
@@ -0,0 +1,187 @@
+<script>
+import AbsenceReasonButtons from "aleksis.apps.kolego/components/AbsenceReasonButtons.vue";
+import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
+import AbsenceReasonGroupSelect from "aleksis.apps.kolego/components/AbsenceReasonGroupSelect.vue";
+import CancelButton from "aleksis.core/components/generic/buttons/CancelButton.vue";
+import MobileFullscreenDialog from "aleksis.core/components/generic/dialogs/MobileFullscreenDialog.vue";
+import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
+import documentationPartMixin from "../documentation/documentationPartMixin";
+import LessonInformation from "../documentation/LessonInformation.vue";
+import { updateParticipationStatuses } from "./participationStatus.graphql";
+import SlideIterator from "aleksis.core/components/generic/SlideIterator.vue";
+
+export default {
+  name: "ManageStudentsDialog",
+  extends: MobileFullscreenDialog,
+  components: {
+    AbsenceReasonChip,
+    AbsenceReasonGroupSelect,
+    AbsenceReasonButtons,
+    CancelButton,
+    LessonInformation,
+    MobileFullscreenDialog,
+    SlideIterator,
+  },
+  mixins: [documentationPartMixin, mutateMixin],
+  data() {
+    return {
+      dialog: false,
+      search: "",
+      loadSelected: false,
+      selected: [],
+      isExpanded: false,
+    };
+  },
+  props: {
+    loadingIndicator: {
+      type: Boolean,
+      default: false,
+      required: false,
+    },
+  },
+  computed: {
+    items() {
+      return this.documentation.participations;
+    },
+  },
+  methods: {
+    sendToServer(participations, field, value) {
+      if (field !== "absenceReason") return;
+
+      this.mutate(
+        updateParticipationStatuses,
+        {
+          input: participations.map((participation) => ({
+            id: participation.id,
+            absenceReason: value === "present" ? null : value,
+          })),
+        },
+        (storedDocumentations, incomingStatuses) => {
+          const documentation = storedDocumentations.find(
+            (doc) => doc.id === this.documentation.id,
+          );
+
+          incomingStatuses.forEach((newStatus) => {
+            const participationStatus = documentation.participations.find(
+              (part) => part.id === newStatus.id,
+            );
+            participationStatus.absenceReason = newStatus.absenceReason;
+            participationStatus.isOptimistic = newStatus.isOptimistic;
+          });
+
+          return storedDocumentations;
+        },
+      );
+    },
+    handleMultipleAction(absenceReasonId) {
+      this.loadSelected = true;
+      this.sendToServer(this.selected, "absenceReason", absenceReasonId);
+      this.$once("save", this.resetMultipleAction);
+    },
+    resetMultipleAction() {
+      this.loadSelected = false;
+      this.$set(this.selected, []);
+      this.$refs.iterator.selected = [];
+    },
+  },
+};
+</script>
+
+<template>
+  <mobile-fullscreen-dialog
+    scrollable
+    v-bind="$attrs"
+    v-on="$listeners"
+    v-model="dialog"
+  >
+    <template #activator="activator">
+      <slot name="activator" v-bind="activator" />
+    </template>
+
+    <template #title>
+      <lesson-information v-bind="documentationPartProps" :compact="false" />
+      <v-scroll-x-transition leave-absolute>
+        <v-text-field
+          v-show="!isExpanded"
+          type="search"
+          v-model="search"
+          clearable
+          rounded
+          hide-details
+          single-line
+          prepend-inner-icon="$search"
+          dense
+          outlined
+          :placeholder="$t('actions.search')"
+          class="pt-4 full-width"
+        />
+      </v-scroll-x-transition>
+      <v-scroll-x-transition>
+        <div v-show="selected.length > 0" class="full-width mt-4">
+          <absence-reason-buttons
+            allow-empty
+            empty-value="present"
+            @input="handleMultipleAction"
+          />
+        </div>
+      </v-scroll-x-transition>
+    </template>
+    <template #content>
+      <slide-iterator
+        ref="iterator"
+        v-model="selected"
+        :items="items"
+        :search="search"
+        :item-key-getter="
+          (item) => 'documentation-' + documentation.id + '-student-' + item.id
+        "
+        :is-expanded.sync="isExpanded"
+        :loading="loadingIndicator || loadSelected"
+        :load-only-selected="loadSelected"
+        :disabled="loading"
+      >
+        <template #listItemContent="{ item }">
+          <v-list-item-title>
+            {{ item.person.fullName }}
+          </v-list-item-title>
+          <v-list-item-subtitle v-if="item.absenceReason">
+            <absence-reason-chip small :absence-reason="item.absenceReason" />
+          </v-list-item-subtitle>
+        </template>
+
+        <template #expandedItem="{ item, close }">
+          <v-card-title>
+            <v-tooltip bottom>
+              <template #activator="{ on, attrs }">
+                <v-btn v-bind="attrs" v-on="on" icon @click="close">
+                  <v-icon>$prev</v-icon>
+                </v-btn>
+              </template>
+              <span v-t="'actions.back_to_overview'" />
+            </v-tooltip>
+            {{ item.person.fullName }}
+          </v-card-title>
+          <v-card-text>
+            <absence-reason-group-select
+              allow-empty
+              empty-value="present"
+              :loadSelectedChip="loading"
+              :value="item.absenceReason?.id || 'present'"
+              @input="sendToServer([item], 'absenceReason', $event)"
+            />
+          </v-card-text>
+        </template>
+      </slide-iterator>
+    </template>
+
+    <template #actions>
+      <cancel-button
+        @click="dialog = false"
+        i18n-key="actions.close"
+        v-show="$vuetify.breakpoint.mobile"
+      />
+    </template>
+  </mobile-fullscreen-dialog>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue
new file mode 100644
index 0000000000000000000000000000000000000000..572036c67955b3365bb46eb69f6ab41ee86cf074
--- /dev/null
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/ManageStudentsTrigger.vue
@@ -0,0 +1,78 @@
+<script>
+import { DateTime } from "luxon";
+import ManageStudentsDialog from "./ManageStudentsDialog.vue";
+import documentationPartMixin from "../documentation/documentationPartMixin";
+import { touchDocumentation } from "./participationStatus.graphql";
+import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
+
+export default {
+  name: "ManageStudentsTrigger",
+  components: { ManageStudentsDialog },
+  mixins: [documentationPartMixin, mutateMixin],
+  data() {
+    return {
+      canOpenParticipation: false,
+      timeout: null,
+    };
+  },
+  mounted() {
+    const lessonStart = DateTime.fromISO(this.documentation.datetimeStart);
+    const now = DateTime.now();
+    this.canOpenParticipation = now >= lessonStart;
+
+    if (!this.canOpenParticipation) {
+      this.timeout = setTimeout(
+        () => (this.canOpenParticipation = true),
+        lessonStart.diff(now).toObject().milliseconds,
+      );
+    }
+  },
+  beforeDestroy() {
+    if (this.timeout) {
+      clearTimeout(this.timeout);
+    }
+  },
+  methods: {
+    touchDocumentation() {
+      this.mutate(
+        touchDocumentation,
+        {
+          documentationId: this.documentation.id,
+        },
+        (storedDocumentations, incoming) => {
+          // ID may be different now
+          return storedDocumentations.map((doc) =>
+            doc.id === this.documentation.id
+              ? Object.assign(doc, incoming, { oldId: doc.id })
+              : doc,
+          );
+        },
+      );
+    },
+  },
+};
+</script>
+
+<template>
+  <manage-students-dialog
+    v-bind="documentationPartProps"
+    @update="() => null"
+    :loading-indicator="loading"
+  >
+    <template #activator="{ attrs, on }">
+      <v-chip
+        dense
+        color="primary"
+        outlined
+        :disabled="!canOpenParticipation || loading"
+        v-bind="attrs"
+        v-on="on"
+        @click="touchDocumentation"
+      >
+        <v-icon>$edit</v-icon>
+      </v-chip>
+    </template>
+  </manage-students-dialog>
+</template>
+
+<style scoped></style>
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..81a3a5fb1eb3a99bef25eb9938cd254b9068981b
--- /dev/null
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/absences/participationStatus.graphql
@@ -0,0 +1,42 @@
+mutation updateParticipationStatuses(
+  $input: [BatchPatchParticipationStatusInput]!
+) {
+  updateParticipationStatuses(input: $input) {
+    items: participationStatuses {
+      id
+      isOptimistic
+      relatedDocumentation {
+        id
+      }
+      absenceReason {
+        id
+        name
+        shortName
+        colour
+      }
+    }
+  }
+}
+
+mutation touchDocumentation($documentationId: ID!) {
+  touchDocumentation(documentationId: $documentationId) {
+    items: documentation {
+      id
+      participations {
+        id
+        person {
+          id
+          firstName
+          fullName
+        }
+        absenceReason {
+          id
+          name
+          shortName
+          colour
+        }
+        isOptimistic
+      }
+    }
+  }
+}
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql
index 8444f9e35af335080026b221424fda598068c6fc..6348a24f189033fc60e97325c0c69cde5d11fbc9 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/coursebook.graphql
@@ -9,10 +9,6 @@ query coursesOfPerson {
   courses: coursesOfPerson {
     id
     name
-    groups {
-      id
-      name
-    }
   }
 }
 
@@ -70,6 +66,21 @@ query documentationsForCoursebook(
       colourFg
       colourBg
     }
+    participations {
+      id
+      person {
+        id
+        firstName
+        fullName
+      }
+      absenceReason {
+        id
+        name
+        shortName
+        colour
+      }
+      isOptimistic
+    }
     topic
     homework
     groupNote
@@ -92,6 +103,21 @@ mutation createOrUpdateDocumentations($input: [DocumentationInputType]!) {
       homework
       groupNote
       oldId
+      participations {
+        id
+        person {
+          id
+          firstName
+          fullName
+        }
+        absenceReason {
+          id
+          name
+          shortName
+          colour
+        }
+        isOptimistic
+      }
     }
   }
 }
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue
index 09a04bcb67c6ae618fa0b1546a0171af30323885..652609dccaf430d3a4ab138f80ee2f810b84b4af 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonInformation.vue
@@ -61,7 +61,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue";
         v-for="teacher in documentation.teachers"
         :key="documentation.id + '-teacher-' + teacher.id"
         :person="teacher"
-        no-link
+        :no-link="compact"
         v-bind="compact ? dialogActivator.attrs : {}"
         v-on="compact ? dialogActivator.on : {}"
       />
@@ -69,7 +69,7 @@ import PersonChip from "aleksis.core/components/person/PersonChip.vue";
         v-for="teacher in amendedTeachers"
         :key="documentation.id + '-amendedTeacher-' + teacher.id"
         :person="teacher"
-        no-link
+        :no-link="compact"
         v-bind="compact ? dialogActivator.attrs : {}"
         v-on="compact ? dialogActivator.on : {}"
         class="text-decoration-line-through"
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue
index f85633f2e6f3864a20db9136f86cee4b51311719..bc0da4a742917e0639a0c1983186fad29764babb 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/LessonNotes.vue
@@ -1,45 +1,71 @@
+<script setup>
+import AbsenceReasonChip from "aleksis.apps.kolego/components/AbsenceReasonChip.vue";
+</script>
+
 <template>
   <div
     class="d-flex align-center justify-space-between justify-md-end flex-wrap gap"
   >
-    <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
-    <v-chip dense color="success">
-      <v-chip small dense class="mr-2" color="green darken-3 white--text"
-        >26</v-chip
-      >
-      von 30 anwesend
-    </v-chip>
-    <v-chip dense color="warning">
-      <v-chip small dense class="mr-2" color="orange darken-3 white--text"
-        >3</v-chip
-      >
-      entschuldigt
-    </v-chip>
-    <v-chip dense color="error">
-      <v-chip small dense class="mr-2" color="red darken-3 white--text"
-        >1</v-chip
-      >
-      unentschuldigt
+    <v-chip dense color="success" outlined v-if="total > 0">
+      {{ $t("alsijil.coursebook.present_number", { present, total }) }}
     </v-chip>
-    <v-chip dense color="grey lighten-1">
-      <v-chip small dense class="mr-2" color="grey darken-1 white--text"
-        >4</v-chip
-      >
-      Hausaufgaben vergessen
-    </v-chip>
-    <v-chip dense color="primary" outlined>
-      <v-icon>$edit</v-icon>
-    </v-chip>
-    <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
+    <absence-reason-chip
+      v-for="[reasonId, participations] in Object.entries(absences)"
+      :key="'reason-' + reasonId"
+      :absence-reason="participations[0].absenceReason"
+      dense
+    >
+      <template #append>
+        <span
+          >:
+          <span>
+            {{
+              participations
+                .slice(0, 5)
+                .map((participation) => participation.person.firstName)
+                .join(", ")
+            }}
+          </span>
+          <span v-if="participations.length > 5">
+            <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+            +{{ participations.length - 5 }}
+            <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
+          </span>
+        </span>
+      </template>
+    </absence-reason-chip>
+
+    <manage-students-trigger v-bind="documentationPartProps" />
   </div>
 </template>
 
 <script>
 import documentationPartMixin from "./documentationPartMixin";
+import ManageStudentsTrigger from "../absences/ManageStudentsTrigger.vue";
 
 export default {
   name: "LessonNotes",
+  components: { ManageStudentsTrigger },
   mixins: [documentationPartMixin],
+  computed: {
+    total() {
+      return this.documentation.participations.length;
+    },
+    present() {
+      return this.documentation.participations.filter(
+        (p) => p.absenceReason === null,
+      ).length;
+    },
+    absences() {
+      // Get all course attendants who have an absence reason
+      return Object.groupBy(
+        this.documentation.participations.filter(
+          (p) => p.absenceReason !== null,
+        ),
+        ({ absenceReason }) => absenceReason.id,
+      );
+    },
+  },
 };
 </script>
 
diff --git a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js
index 165f1d2fd157bb35bf2831fc7973f480b29ccd0a..88a8e852f8cc6e333303034fb5f590d174708886 100644
--- a/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js
+++ b/aleksis/apps/alsijil/frontend/components/coursebook/documentation/documentationPartMixin.js
@@ -10,6 +10,13 @@ export default {
       type: Object,
       required: true,
     },
+    /**
+     * The query used by the coursebook. Used to update the store when data changes.
+     */
+    affectedQuery: {
+      type: Object,
+      required: true,
+    },
     /**
      * Whether the documentation is currently in the compact mode (meaning coursebook row)
      */
@@ -38,6 +45,7 @@ export default {
         documentation: this.documentation,
         compact: this.compact,
         dialogActivator: this.dialogActivator,
+        affectedQuery: this.affectedQuery,
       };
     },
   },
diff --git a/aleksis/apps/alsijil/frontend/messages/de.json b/aleksis/apps/alsijil/frontend/messages/de.json
index b193697758ac938f7e92bcfdf90c7bcd5c5bbca7..31ae2d9763a98f946aa896a9ab79d5fc8d514a0f 100644
--- a/aleksis/apps/alsijil/frontend/messages/de.json
+++ b/aleksis/apps/alsijil/frontend/messages/de.json
@@ -49,13 +49,27 @@
           }
         }
       },
-      "title_plural": "Kursbuch"
+      "title_plural": "Kursbuch",
+      "present_number": "{present}/{total} anwesend"
     },
     "excuse_types": {
       "menu_title": "Entschuldigungsarten"
     },
     "extra_marks": {
-      "menu_title": "Zusätzliche Markierungen"
+      "menu_title": "Zusätzliche Markierungen",
+      "create": "Markierung erstellen",
+      "name": "Markierung",
+      "short_name": "Abkürzung",
+      "colour_fg": "Schriftfarbe",
+      "colour_bg": "Hintergrundfarbe",
+      "show_in_coursebook": "In Kursbuch-Übersicht zeigen",
+      "show_in_coursebook_helptext": "Wenn aktiviert tauchen diese Markierungen in den Zeilen im Kursbuch auf."
+    },
+    "personal_notes": {
+      "note": "Notiz",
+      "create_personal_note": "Weitere Notiz",
+      "confirm_delete": "Notiz wirklich löschen?",
+      "confirm_delete_explanation": "Die Notiz \"{note}\" für {name} wird entfernt."
     },
     "group_roles": {
       "menu_title_assign": "Gruppenrollen zuweisen",
@@ -77,5 +91,8 @@
     "week": {
       "menu_title": "Aktuelle Woche"
     }
+  },
+  "actions": {
+    "back_to_overview": "Zurück zur Übersicht"
   }
 }
diff --git a/aleksis/apps/alsijil/frontend/messages/en.json b/aleksis/apps/alsijil/frontend/messages/en.json
index acadfea9a598f5edf1952cd205166db7b6ecde62..71a509cfec66659437760a1c2ab2fc41ad35cd3c 100644
--- a/aleksis/apps/alsijil/frontend/messages/en.json
+++ b/aleksis/apps/alsijil/frontend/messages/en.json
@@ -74,8 +74,12 @@
         "courses": "Courses",
         "filter_for_obj": "Filter for group and course"
       },
+      "present_number": "{present}/{total} present",
       "no_data": "No lessons for the selected groups and courses in this period",
       "no_results": "No search results for {search}"
     }
+  },
+  "actions": {
+    "back_to_overview": "Back to overview"
   }
 }
diff --git a/aleksis/apps/alsijil/managers.py b/aleksis/apps/alsijil/managers.py
index 2e8b2e558e78c84ac83674840f0a3f8b2eb259c5..7d0130805275359672d1337133eacd1b0a2b62ad 100644
--- a/aleksis/apps/alsijil/managers.py
+++ b/aleksis/apps/alsijil/managers.py
@@ -11,7 +11,7 @@ from django.utils.translation import gettext as _
 from calendarweek import CalendarWeek
 
 from aleksis.apps.chronos.managers import DateRangeQuerySetMixin
-from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations
+from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, PolymorphicBaseManager
 
 if TYPE_CHECKING:
     from aleksis.core.models import Group
@@ -187,3 +187,27 @@ class GroupRoleAssignmentQuerySet(DateRangeQuerySetMixin, QuerySet):
     def for_group(self, group: "Group"):
         """Filter all role assignments for a group."""
         return self.filter(Q(groups=group) | Q(groups__child_groups=group))
+
+
+class DocumentationManager(PolymorphicBaseManager):
+    """Manager adding specific methods to documentations."""
+
+    def get_queryset(self):
+        """Ensure often used related data are loaded as well."""
+        return (
+            super()
+            .get_queryset()
+            .select_related(
+                "course",
+                "subject",
+            )
+            .prefetch_related("teachers")
+        )
+
+
+class ParticipationStatusManager(PolymorphicBaseManager):
+    """Manager adding specific methods to participation statuses."""
+
+    def get_queryset(self):
+        """Ensure often used related data are loaded as well."""
+        return super().get_queryset().select_related("person", "absence_reason", "base_absence")
diff --git a/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py b/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py
new file mode 100644
index 0000000000000000000000000000000000000000..052c763671823d550eb8fa6265cff67fa20daf80
--- /dev/null
+++ b/aleksis/apps/alsijil/migrations/0021_remove_participationstatus_absent_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.10 on 2024-04-30 11:14
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kolego', '0003_refactor_absence'),
+        ('alsijil', '0020_documentation_extramark_colour_bg_and_more'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='participationstatus',
+            name='absent',
+        ),
+        migrations.AlterField(
+            model_name='participationstatus',
+            name='absence_reason',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='kolego.absencereason', verbose_name='Absence Reason'),
+        ),
+    ]
diff --git a/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py b/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef09ddca37a893128571818368982e29bd0c219f
--- /dev/null
+++ b/aleksis/apps/alsijil/migrations/0022_documentation_participation_touched_at.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.6 on 2024-06-06 09:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('alsijil', '0021_remove_participationstatus_absent_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='documentation',
+            name='participation_touched_at',
+            field=models.DateTimeField(blank=True, null=True, verbose_name='Participation touched at'),
+        ),
+    ]
diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py
index 332c428fa7eff2095dc3ec83eb013ee759bab42b..9e68ccd06773dc8b0f6ee65ccd16c81319b94a8b 100644
--- a/aleksis/apps/alsijil/models.py
+++ b/aleksis/apps/alsijil/models.py
@@ -2,13 +2,16 @@ from datetime import date, datetime
 from typing import Optional, Union
 from urllib.parse import urlparse
 
+from django.contrib.auth.models import User
+from django.core.exceptions import PermissionDenied
 from django.db import models
 from django.db.models import QuerySet
 from django.db.models.constraints import CheckConstraint
 from django.db.models.query_utils import Q
-from django.http import HttpRequest
 from django.urls import reverse
+from django.utils import timezone
 from django.utils.formats import date_format
+from django.utils.timezone import localdate, localtime, now
 from django.utils.translation import gettext_lazy as _
 
 from calendarweek import CalendarWeek
@@ -22,12 +25,14 @@ from aleksis.apps.alsijil.data_checks import (
     PersonalNoteOnHolidaysDataCheck,
 )
 from aleksis.apps.alsijil.managers import (
+    DocumentationManager,
     GroupRoleAssignmentManager,
     GroupRoleAssignmentQuerySet,
     GroupRoleManager,
     GroupRoleQuerySet,
     LessonDocumentationManager,
     LessonDocumentationQuerySet,
+    ParticipationStatusManager,
     PersonalNoteManager,
     PersonalNoteQuerySet,
 )
@@ -40,7 +45,7 @@ from aleksis.apps.kolego.models import Absence as KolegoAbsence
 from aleksis.apps.kolego.models import AbsenceReason
 from aleksis.core.data_checks import field_validation_data_check_factory
 from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel
-from aleksis.core.models import CalendarEvent, Group, SchoolTerm
+from aleksis.core.models import CalendarEvent, Group, Person, SchoolTerm
 from aleksis.core.util.core_helpers import get_site_preferences
 from aleksis.core.util.model_helpers import ICONS
 
@@ -458,6 +463,8 @@ class Documentation(CalendarEvent):
 
     # FIXME: DataCheck
 
+    objects = DocumentationManager()
+
     course = models.ForeignKey(
         Course,
         models.PROTECT,
@@ -483,6 +490,11 @@ class Documentation(CalendarEvent):
     homework = models.CharField(verbose_name=_("Homework"), max_length=255, blank=True)
     group_note = models.CharField(verbose_name=_("Group Note"), max_length=255, blank=True)
 
+    # Used to track whether participations have been filled in
+    participation_touched_at = models.DateTimeField(
+        blank=True, null=True, verbose_name=_("Participation touched at")
+    )
+
     def get_subject(self) -> str:
         if self.subject:
             return self.subject
@@ -515,44 +527,17 @@ class Documentation(CalendarEvent):
         # which is not possible via constraint, because amends is not local to Documentation
 
     @classmethod
-    def get_for_coursebook(
+    def get_documentations_for_events(
         cls,
-        own: bool,
-        date_start: datetime,
-        date_end: datetime,
-        request: HttpRequest,
-        obj_type: Optional[str] = None,
-        obj_id: Optional[str] = None,
+        events: list,
         incomplete: Optional[bool] = False,
-    ) -> list:
-        """Get all the documentations for an object and a time frame.
-
-        obj_type may be one of TEACHER, GROUP, ROOM, COURSE
+    ) -> tuple:
+        """Get all the documentations for the events.
+        Create dummy documentations if none exist.
+        Returns a tuple with a list of existing documentations and a list dummy documentations.
         """
-
-        # 1. Find all LessonEvents for all Lessons of this Course in this date range
-        event_params = {
-            "own": own,
-        }
-        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,
-            event_params,
-            with_reference_object=True,
-        )
-
-        # 2. For each lessonEvent → check if there is a documentation
-        # if so, add the documentation to a list, if not, create a new one
         docs = []
+        dummies = []
         for event in events:
             if incomplete and event["STATUS"] == "CANCELLED":
                 continue
@@ -582,7 +567,7 @@ class Documentation(CalendarEvent):
                 else:
                     course, subject = event_reference_obj.course, event_reference_obj.subject
 
-                docs.append(
+                dummies.append(
                     cls(
                         pk=f"DUMMY;{event_reference_obj.id};{event['DTSTART'].dt.isoformat()};{event['DTEND'].dt.isoformat()}",
                         amends=event_reference_obj,
@@ -593,7 +578,173 @@ class Documentation(CalendarEvent):
                     )
                 )
 
-        return docs
+        return (docs, dummies)
+
+    @classmethod
+    def get_documentations_for_person(
+        cls,
+        person: int,
+        start: datetime,
+        end: datetime,
+        incomplete: Optional[bool] = False,
+    ) -> tuple:
+        """Get all the documentations for the person from start to end datetime.
+        Create dummy documentations if none exist.
+        Returns a tuple with a list of existing documentations and a list dummy documentations.
+        """
+        event_params = {
+            "type": "PARTICIPANT",
+            "obj_id": person,
+        }
+
+        events = LessonEvent.get_single_events(
+            start,
+            end,
+            None,
+            event_params,
+            with_reference_object=True,
+        )
+
+        return Documentation.get_documentations_for_events(events, incomplete)
+
+    @classmethod
+    def parse_dummy(
+        cls,
+        _id: str,
+    ) -> tuple:
+        """Parse dummy id string into lesson_event, datetime_start, datetime_end."""
+        dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";")
+        lesson_event = LessonEvent.objects.get(id=lesson_event_id)
+
+        datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone(
+            lesson_event.timezone
+        )
+        datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(lesson_event.timezone)
+        return (lesson_event, datetime_start, datetime_end)
+
+    @classmethod
+    def create_from_lesson_event(
+        cls,
+        user: User,
+        lesson_event: LessonEvent,
+        datetime_start: datetime,
+        datetime_end: datetime,
+    ) -> "Documentation":
+        """Create a documentation from a lesson_event with start and end datetime.
+        User is needed for permission checking.
+        """
+        if not user.has_perm(
+            "alsijil.add_documentation_for_lesson_event_rule", lesson_event
+        ) or not (
+            get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
+            or (
+                get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_day"
+                and datetime_start.date() <= localdate()
+            )
+            or (
+                get_site_preferences()["alsijil__allow_edit_future_documentations"]
+                == "current_time"
+                and datetime_start <= localtime()
+            )
+        ):
+            raise PermissionDenied()
+
+        if lesson_event.amends:
+            course = lesson_event.course if lesson_event.course else lesson_event.amends.course
+
+            subject = lesson_event.subject if lesson_event.subject else lesson_event.amends.subject
+
+            teachers = (
+                lesson_event.teachers if lesson_event.teachers else lesson_event.amends.teachers
+            )
+        else:
+            course, subject, teachers = (
+                lesson_event.course,
+                lesson_event.subject,
+                lesson_event.teachers,
+            )
+
+        obj = cls.objects.create(
+            datetime_start=datetime_start,
+            datetime_end=datetime_end,
+            amends=lesson_event,
+            course=course,
+            subject=subject,
+        )
+        obj.teachers.set(teachers.all())
+        obj.save()
+
+        # Create Participation Statuses
+        obj.touch()
+
+        return obj
+
+    @classmethod
+    def get_or_create_by_id(cls, _id: str | int, user):
+        if _id.startswith("DUMMY"):
+            return cls.create_from_lesson_event(
+                user,
+                *cls.parse_dummy(_id),
+            ), True
+
+        return cls.objects.get(id=_id), False
+
+    def touch(self):
+        """Ensure that participation statuses are created for this documentation."""
+        if (
+            self.participation_touched_at
+            or not self.amends
+            or self.value_start_datetime(self) > now()
+        ):
+            # There is no source to update from or it's too early
+            return
+
+        lesson_event: LessonEvent = self.amends
+        all_members = lesson_event.all_members
+        member_pks = [p.pk for p in all_members]
+
+        new_persons = Person.objects.filter(Q(pk__in=member_pks)).prefetch_related("member_of")
+
+        # Get absences from Kolego
+        events = KolegoAbsence.get_single_events(
+            self.value_start_datetime(self),
+            self.value_end_datetime(self),
+            None,
+            {"persons": member_pks},
+            with_reference_object=True,
+        )
+        kolego_absences_map = {a["REFERENCE_OBJECT"].person: a["REFERENCE_OBJECT"] for a in events}
+
+        new_participations = []
+        new_groups_of_person = []
+        for person in new_persons:
+            participation_status = ParticipationStatus(
+                person=person,
+                related_documentation=self,
+                datetime_start=self.datetime_start,
+                datetime_end=self.datetime_end,
+                timezone=self.timezone,
+            )
+
+            # Take over data from Kolego absence
+            if person in kolego_absences_map:
+                participation_status.fill_from_kolego(kolego_absences_map[person])
+
+            participation_status.save()
+
+            new_groups_of_person += [
+                ParticipationStatus.groups_of_person.through(
+                    group=group, participationstatus=participation_status
+                )
+                for group in person.member_of.all()
+            ]
+            new_participations.append(participation_status)
+        ParticipationStatus.groups_of_person.through.objects.bulk_create(new_groups_of_person)
+
+        self.participation_touched_at = timezone.now()
+        self.save()
+
+        return new_participations
 
 
 class ParticipationStatus(CalendarEvent):
@@ -605,6 +756,8 @@ class ParticipationStatus(CalendarEvent):
 
     # FIXME: DataChecks
 
+    objects = ParticipationStatusManager()
+
     person = models.ForeignKey(
         "core.Person", models.CASCADE, related_name="participations", verbose_name=_("Person")
     )
@@ -620,9 +773,12 @@ class ParticipationStatus(CalendarEvent):
     )
 
     # Absence part
-    absent = models.BooleanField(verbose_name=_("Absent"))
     absence_reason = models.ForeignKey(
-        AbsenceReason, verbose_name=_("Absence Reason"), on_delete=models.PROTECT
+        AbsenceReason,
+        verbose_name=_("Absence Reason"),
+        on_delete=models.PROTECT,
+        blank=True,
+        null=True,
     )
 
     base_absence = models.ForeignKey(
@@ -634,8 +790,13 @@ class ParticipationStatus(CalendarEvent):
         verbose_name=_("Base Absence"),
     )
 
+    def fill_from_kolego(self, kolego_absence: KolegoAbsence):
+        """Take over data from a Kolego absence."""
+        self.base_absence = kolego_absence
+        self.absence_reason = kolego_absence.reason
+
     def __str__(self) -> str:
-        return f"{self.related_documentation}, {self.person}"
+        return f"{self.related_documentation.id}, {self.person}"
 
     class Meta:
         verbose_name = _("Participation Status")
diff --git a/aleksis/apps/alsijil/rules.py b/aleksis/apps/alsijil/rules.py
index b7fa4d04a23df031ff479870c17cf5f1a3d53446..9045598fdf362f0bedb57424fd73bb450d80d669 100644
--- a/aleksis/apps/alsijil/rules.py
+++ b/aleksis/apps/alsijil/rules.py
@@ -12,8 +12,10 @@ from aleksis.core.util.predicates import (
 
 from .util.predicates import (
     can_edit_documentation,
+    can_edit_participation_status,
     can_view_any_documentation,
     can_view_documentation,
+    can_view_participation_status,
     has_lesson_group_object_perm,
     has_person_group_object_perm,
     has_personal_note_group_perm,
@@ -24,6 +26,7 @@ from .util.predicates import (
     is_group_owner,
     is_group_role_assignment_group_owner,
     is_in_allowed_time_range,
+    is_in_allowed_time_range_for_participation_status,
     is_lesson_event_group_owner,
     is_lesson_event_teacher,
     is_lesson_original_teacher,
@@ -414,3 +417,21 @@ edit_documentation_predicate = (
 )
 add_perm("alsijil.edit_documentation_rule", edit_documentation_predicate)
 add_perm("alsijil.delete_documentation_rule", edit_documentation_predicate)
+
+view_participation_status_for_documentation_predicate = has_person & (
+    has_global_perm("alsijil.change_participationstatus") | can_view_participation_status
+)
+add_perm(
+    "alsijil.view_participation_status_for_documentation_rule",
+    view_participation_status_for_documentation_predicate,
+)
+
+edit_participation_status_for_documentation_predicate = (
+    has_person
+    & (has_global_perm("alsijil.change_participationstatus") | can_edit_participation_status)
+    & is_in_allowed_time_range_for_participation_status
+)
+add_perm(
+    "alsijil.edit_participation_status_for_documentation_rule",
+    edit_participation_status_for_documentation_predicate,
+)
diff --git a/aleksis/apps/alsijil/schema/__init__.py b/aleksis/apps/alsijil/schema/__init__.py
index aae1b70603f0b0e5c738d81467d31f3844f13a86..48e1c20c62434cb9154d9e0faa9904dd09c84641 100644
--- a/aleksis/apps/alsijil/schema/__init__.py
+++ b/aleksis/apps/alsijil/schema/__init__.py
@@ -5,6 +5,7 @@ from django.db.models.query_utils import Q
 
 import graphene
 
+from aleksis.apps.chronos.models import LessonEvent
 from aleksis.apps.cursus.models import Course
 from aleksis.apps.cursus.schema import CourseType
 from aleksis.core.models import Group, Person
@@ -16,7 +17,10 @@ from ..models import Documentation
 from .documentation import (
     DocumentationBatchCreateOrUpdateMutation,
     DocumentationType,
+    LessonsForPersonType,
+    TouchDocumentationMutation,
 )
+from .participation_status import ParticipationStatusBatchPatchMutation
 
 
 class Query(graphene.ObjectType):
@@ -37,6 +41,13 @@ class Query(graphene.ObjectType):
     groups_by_person = FilterOrderList(GroupType, person=graphene.ID())
     courses_of_person = FilterOrderList(CourseType, person=graphene.ID())
 
+    lessons_for_persons = graphene.List(
+        LessonsForPersonType,
+        persons=graphene.List(graphene.ID, required=True),
+        start=graphene.Date(required=True),
+        end=graphene.Date(required=True),
+    )
+
     def resolve_documentations_by_course_id(root, info, course_id, **kwargs):
         documentations = Documentation.objects.filter(
             Q(course__pk=course_id) | Q(amends__course__pk=course_id)
@@ -54,9 +65,6 @@ class Query(graphene.ObjectType):
         incomplete=False,
         **kwargs,
     ):
-        datetime_start = datetime.combine(date_start, datetime.min.time())
-        datetime_end = datetime.combine(date_end, datetime.max.time())
-
         if (
             (
                 obj_type == "COURSE"
@@ -79,10 +87,30 @@ class Query(graphene.ObjectType):
         ):
             raise PermissionDenied()
 
-        return Documentation.get_for_coursebook(
-            own, datetime_start, datetime_end, info.context, obj_type, obj_id, incomplete
+        # Find all LessonEvents for all Lessons of this Course in this date range
+        event_params = {
+            "own": own,
+        }
+        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(
+            datetime.combine(date_start, datetime.min.time()),
+            datetime.combine(date_end, datetime.max.time()),
+            info.context,
+            event_params,
+            with_reference_object=True,
         )
 
+        # Lookup or create documentations and return them all.
+        docs, dummies = Documentation.get_documentations_for_events(events, incomplete)
+        return docs + dummies
+
     @staticmethod
     def resolve_groups_by_person(root, info, person=None):
         if person:
@@ -116,6 +144,30 @@ class Query(graphene.ObjectType):
             | Q(groups__parent_groups__owners=person)
         )
 
+    @staticmethod
+    def resolve_lessons_for_persons(
+        root,
+        info,
+        persons,
+        start,
+        end,
+        **kwargs,
+    ):
+        """Resolve all lesson events for each person in timeframe start to end."""
+        lessons_for_person = []
+        for person in persons:
+            docs, dummies = Documentation.get_documentations_for_person(
+                person,
+                datetime.combine(start, datetime.min.time()),
+                datetime.combine(end, datetime.max.time()),
+            )
+
+            lessons_for_person.append(id=person, lessons=docs + dummies)
+
+        return lessons_for_person
+
 
 class Mutation(graphene.ObjectType):
     create_or_update_documentations = DocumentationBatchCreateOrUpdateMutation.Field()
+    touch_documentation = TouchDocumentationMutation.Field()
+    update_participation_statuses = ParticipationStatusBatchPatchMutation.Field()
diff --git a/aleksis/apps/alsijil/schema/documentation.py b/aleksis/apps/alsijil/schema/documentation.py
index 3225db34e7c146c4eadcf34efcc11936f5df7311..4a0055064abd0a9cc1bb79850390da318a808b4a 100644
--- a/aleksis/apps/alsijil/schema/documentation.py
+++ b/aleksis/apps/alsijil/schema/documentation.py
@@ -1,15 +1,10 @@
-from datetime import datetime
-
 from django.core.exceptions import PermissionDenied
-from django.utils.timezone import localdate, localtime
 
 import graphene
 from graphene_django.types import DjangoObjectType
-from guardian.shortcuts import get_objects_for_user
 from reversion import create_revision, set_comment, set_user
 
 from aleksis.apps.alsijil.util.predicates import can_edit_documentation, is_in_allowed_time_range
-from aleksis.apps.chronos.models import LessonEvent
 from aleksis.apps.chronos.schema import LessonEventType
 from aleksis.apps.cursus.models import Subject
 from aleksis.apps.cursus.schema import CourseType, SubjectType
@@ -18,9 +13,9 @@ from aleksis.core.schema.base import (
     DjangoFilterMixin,
     PermissionsTypeMixin,
 )
-from aleksis.core.util.core_helpers import get_site_preferences
 
 from ..models import Documentation
+from .participation_status import ParticipationStatusType
 
 
 class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
@@ -39,6 +34,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
             "date_start",
             "date_end",
             "teachers",
+            "participations",
         )
         filter_fields = {
             "id": ["exact", "lte", "gte"],
@@ -48,6 +44,7 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
     course = graphene.Field(CourseType, required=False)
     amends = graphene.Field(lambda: LessonEventType, required=False)
     subject = graphene.Field(SubjectType, required=False)
+    participations = graphene.List(ParticipationStatusType, required=False)
 
     future_notice = graphene.Boolean(required=False)
 
@@ -71,9 +68,17 @@ class DocumentationType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectTyp
             info.context.user, root
         )
 
-    @classmethod
-    def get_queryset(cls, queryset, info):
-        return get_objects_for_user(info.context.user, "alsijil.view_documentation", queryset)
+    @staticmethod
+    def resolve_participations(root: Documentation, info, **kwargs):
+        if not info.context.user.has_perm(
+            "alsijil.view_participation_status_for_documentation", root
+        ):
+            return []
+
+        # A dummy documentation will not have any participations
+        if str(root.pk).startswith("DUMMY") or not hasattr(root, "participations"):
+            return []
+        return root.participations.select_related("absence_reason", "base_absence").all()
 
 
 class DocumentationInputType(graphene.InputObjectType):
@@ -87,6 +92,11 @@ class DocumentationInputType(graphene.InputObjectType):
     group_note = graphene.String(required=False)
 
 
+class LessonsForPersonType(graphene.ObjectType):
+    id = graphene.ID()  # noqa
+    lessons = graphene.List(DocumentationType)
+
+
 class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
     class Arguments:
         input = graphene.List(DocumentationInputType)
@@ -99,91 +109,25 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
 
         # Sadly, we can't use the update_or_create method since create_defaults
         # is only introduced in Django 5.0
-        if _id.startswith("DUMMY"):
-            dummy, lesson_event_id, datetime_start_iso, datetime_end_iso = _id.split(";")
-            lesson_event = LessonEvent.objects.get(id=lesson_event_id)
-
-            datetime_start = datetime.fromisoformat(datetime_start_iso).astimezone(
-                lesson_event.timezone
-            )
-            datetime_end = datetime.fromisoformat(datetime_end_iso).astimezone(
-                lesson_event.timezone
-            )
-
-            if info.context.user.has_perm(
-                "alsijil.add_documentation_for_lesson_event_rule", lesson_event
-            ) and (
-                get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
-                or (
-                    get_site_preferences()["alsijil__allow_edit_future_documentations"]
-                    == "current_day"
-                    and datetime_start.date() <= localdate()
-                )
-                or (
-                    get_site_preferences()["alsijil__allow_edit_future_documentations"]
-                    == "current_time"
-                    and datetime_start <= localtime()
-                )
-            ):
-                if lesson_event.amends:
-                    if lesson_event.course:
-                        course = lesson_event.course
-                    else:
-                        course = lesson_event.amends.course
-
-                    if lesson_event.subject:
-                        subject = lesson_event.subject
-                    else:
-                        subject = lesson_event.amends.subject
-
-                    if lesson_event.teachers:
-                        teachers = lesson_event.teachers
-                    else:
-                        teachers = lesson_event.amends.teachers
-                else:
-                    course, subject, teachers = (
-                        lesson_event.course,
-                        lesson_event.subject,
-                        lesson_event.teachers,
-                    )
-
-                obj = Documentation.objects.create(
-                    datetime_start=datetime_start,
-                    datetime_end=datetime_end,
-                    amends=lesson_event,
-                    course=course,
-                    subject=subject,
-                    topic=doc.topic or "",
-                    homework=doc.homework or "",
-                    group_note=doc.group_note or "",
-                )
-                if doc.teachers is not None:
-                    obj.teachers.add(*doc.teachers)
-                else:
-                    obj.teachers.set(teachers.all())
-                obj.save()
-                return obj
-            raise PermissionDenied()
-        else:
-            obj = Documentation.objects.get(id=_id)
+        obj, __ = Documentation.get_or_create_by_id(_id, info.context.user)
 
-            if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj):
-                raise PermissionDenied()
+        if not info.context.user.has_perm("alsijil.edit_documentation_rule", obj):
+            raise PermissionDenied()
 
-            if doc.topic is not None:
-                obj.topic = doc.topic
-            if doc.homework is not None:
-                obj.homework = doc.homework
-            if doc.group_note is not None:
-                obj.group_note = doc.group_note
+        if doc.topic is not None:
+            obj.topic = doc.topic
+        if doc.homework is not None:
+            obj.homework = doc.homework
+        if doc.group_note is not None:
+            obj.group_note = doc.group_note
 
-            if doc.subject is not None:
-                obj.subject = Subject.objects.get(pk=doc.subject)
-            if doc.teachers is not None:
-                obj.teachers.set(Person.objects.filter(pk__in=doc.teachers))
+        if doc.subject is not None:
+            obj.subject = Subject.objects.get(pk=doc.subject)
+        if doc.teachers is not None:
+            obj.teachers.set(Person.objects.filter(pk__in=doc.teachers))
 
-            obj.save()
-            return obj
+        obj.save()
+        return obj
 
     @classmethod
     def mutate(cls, root, info, input):  # noqa
@@ -193,3 +137,25 @@ class DocumentationBatchCreateOrUpdateMutation(graphene.Mutation):
             objs = [cls.create_or_update(info, doc) for doc in input]
 
         return DocumentationBatchCreateOrUpdateMutation(documentations=objs)
+
+
+class TouchDocumentationMutation(graphene.Mutation):
+    class Arguments:
+        documentation_id = graphene.ID(required=True)
+
+    documentation = graphene.Field(DocumentationType)
+
+    def mutate(root, info, documentation_id):
+        documentation, created = Documentation.get_or_create_by_id(
+            documentation_id, info.context.user
+        )
+
+        if not info.context.user.has_perm(
+            "alsijil.edit_participation_status_for_documentation_rule", documentation
+        ):
+            raise PermissionDenied()
+
+        if not created:
+            documentation.touch()
+
+        return TouchDocumentationMutation(documentation=documentation)
diff --git a/aleksis/apps/alsijil/schema/participation_status.py b/aleksis/apps/alsijil/schema/participation_status.py
new file mode 100644
index 0000000000000000000000000000000000000000..246ae52a0aab7e7bf57f102ccd7e6558d4639496
--- /dev/null
+++ b/aleksis/apps/alsijil/schema/participation_status.py
@@ -0,0 +1,46 @@
+from django.core.exceptions import PermissionDenied
+
+from graphene_django import DjangoObjectType
+
+from aleksis.apps.alsijil.models import ParticipationStatus
+from aleksis.core.schema.base import (
+    BaseBatchPatchMutation,
+    DjangoFilterMixin,
+    OptimisticResponseTypeMixin,
+    PermissionsTypeMixin,
+)
+
+
+class ParticipationStatusType(
+    OptimisticResponseTypeMixin,
+    PermissionsTypeMixin,
+    DjangoFilterMixin,
+    DjangoObjectType,
+):
+    class Meta:
+        model = ParticipationStatus
+        fields = (
+            "id",
+            "person",
+            "absence_reason",
+            "related_documentation",
+            "base_absence",
+        )
+
+
+class ParticipationStatusBatchPatchMutation(BaseBatchPatchMutation):
+    class Meta:
+        model = ParticipationStatus
+        fields = ("id", "absence_reason")  # Only the reason can be updated after creation
+        return_field_name = "participationStatuses"
+
+    @classmethod
+    def check_permissions(cls, root, info, input, *args, **kwargs):  # noqa: A002
+        pass
+
+    @classmethod
+    def after_update_obj(cls, root, info, input, obj, full_input):  # noqa: A002
+        if not info.context.user.has_perm(
+            "alsijil.edit_participation_status_for_documentation_rule", obj.related_documentation
+        ):
+            raise PermissionDenied()
diff --git a/aleksis/apps/alsijil/util/predicates.py b/aleksis/apps/alsijil/util/predicates.py
index fe7746948d807f3afede2a8fd3a11b41358965f5..9f06195e279b6e9bc9564731146d33ea7995498d 100644
--- a/aleksis/apps/alsijil/util/predicates.py
+++ b/aleksis/apps/alsijil/util/predicates.py
@@ -2,7 +2,7 @@ from typing import Any, Union
 
 from django.contrib.auth.models import User
 from django.db.models import Q
-from django.utils.timezone import localdate, localtime
+from django.utils.timezone import localdate, now
 
 from rules import predicate
 
@@ -420,11 +420,17 @@ def can_view_any_documentation(user: User):
     """Predicate which checks if the user is allowed to view any documentation."""
     allowed_lesson_events = LessonEvent.objects.related_to_person(user.person)
 
-    return Documentation.objects.filter(
+    if allowed_lesson_events.exists():
+        return True
+
+    if Documentation.objects.filter(
         Q(teachers=user.person)
         | Q(amends__in=allowed_lesson_events)
         | Q(course__teachers=user.person)
-    ).exists()
+    ).exists():
+        return True
+
+    return False
 
 
 @predicate
@@ -440,6 +446,34 @@ def can_edit_documentation(user: User, obj: Documentation):
     return False
 
 
+@predicate
+def can_view_participation_status(user: User, obj: Documentation):
+    """Predicate which checks if the user is allowed to view participation for a documentation."""
+    if obj:
+        if is_documentation_teacher(user, obj):
+            return True
+        if obj.amends:
+            return is_lesson_event_teacher(user, obj.amends) | is_lesson_event_group_owner(
+                user, obj.amends
+            )
+        if obj.course:
+            return is_course_teacher(user, obj.course)
+    return False
+
+
+@predicate
+def can_edit_participation_status(user: User, obj: Documentation):
+    """Predicate which checks if the user is allowed to edit participation for a documentation."""
+    if obj:
+        if is_documentation_teacher(user, obj):
+            return True
+        if obj.amends:
+            return is_lesson_event_teacher(user, obj.amends) | is_lesson_event_group_owner(
+                user, obj.amends
+            )
+    return False
+
+
 @predicate
 def is_in_allowed_time_range(user: User, obj: Documentation):
     """Predicate which checks if the documentation is in the allowed time range for editing."""
@@ -447,12 +481,20 @@ def is_in_allowed_time_range(user: User, obj: Documentation):
         get_site_preferences()["alsijil__allow_edit_future_documentations"] == "all"
         or (
             get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_day"
-            and obj.datetime_start.date() <= localdate()
+            and obj.value_start_datetime(obj).date() <= localdate()
         )
         or (
             get_site_preferences()["alsijil__allow_edit_future_documentations"] == "current_time"
-            and obj.datetime_start <= localtime()
+            and obj.value_start_datetime(obj) <= now()
         )
     ):
         return True
     return False
+
+
+@predicate
+def is_in_allowed_time_range_for_participation_status(user: User, obj: Documentation):
+    """Predicate which checks if the documentation is in the allowed time range for editing."""
+    if obj and obj.value_start_datetime(obj) <= now():
+        return True
+    return False
diff --git a/pyproject.toml b/pyproject.toml
index 276683bae087de62a39719ae078ecc0b223a0590..03a0772bee8aa34ffc35dc565a1762b87745abbb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,7 +52,7 @@ python = "^3.10"
 aleksis-core = "^4.0.0.dev7"
 aleksis-app-chronos = "^4.0.0.dev3"
 aleksis-app-stoelindeling = { version = "^3.0.dev1", optional = true }
-aleksis-app-kolego = "^0.1.0.dev0"
+aleksis-app-kolego = "^0.1.0.dev2"
 
 [tool.poetry.extras]
 seatingplans = ["aleksis-app-stoelindeling"]