diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 3aff631b5906058a5b3b7f8389acc2a1e99c4c96..7d4c2b780f85d61eafe9e169b0ae012fe1fba30a 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -61,6 +61,7 @@ Added
 * Make configurable which weekdays appear in the calendar
 * Introduce .well-known urlpatterns for apps
 * Global school term select for limiting data to a specific school term.
+* [Dev] Notifications based on calendar alarms.
 
 Changed
 ~~~~~~~
@@ -74,7 +75,8 @@ Changed
 * Move "Invite person" to persons page
 * Replace all mentions of Redis with Valkey where possible
 * Show avatars of groups in all places.
-* Use new auth rate limiting settings 
+* Use new auth rate limiting settings
+* Bump Python version to 3.10
 
 Fixed
 ~~~~~
diff --git a/Dockerfile b/Dockerfile
index 44cb3eec27c4eadc5de78328be22926011a875f1..38dec3ced173a8329f8e7e57e25d34351021f844 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -73,7 +73,6 @@ RUN set -e; \
 
 # Define entrypoint, volumes and uWSGI running on port 8000
 EXPOSE 8000
-VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
 COPY docker-startup.sh /usr/local/bin/aleksis-docker-startup
 ENTRYPOINT ["/usr/bin/dumb-init", "--"]
 CMD ["/usr/local/bin/aleksis-docker-startup"]
@@ -111,6 +110,7 @@ RUN chown -R www-data:www-data \
      ${ALEKSIS_media__root} \
      ${ALEKSIS_backup__location}
 USER 33:33
+VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location}
 
 # Additional steps
 ONBUILD ARG APPS
diff --git a/aleksis/core/frontend/components/about/installedApps.graphql b/aleksis/core/frontend/components/about/installedApps.graphql
index 01ceaf99eb8d79d44ee7014d9f582d7dfeb54ace..be2a7d1995f8107a3588f62d9d08c3e35c5aeec6 100644
--- a/aleksis/core/frontend/components/about/installedApps.graphql
+++ b/aleksis/core/frontend/components/about/installedApps.graphql
@@ -1,4 +1,4 @@
-{
+query installedApps {
   installedApps {
     name
     verboseName
diff --git a/aleksis/core/frontend/components/app/customMenu.graphql b/aleksis/core/frontend/components/app/customMenu.graphql
index 9591126f8226a590355969d643a5db9257c2b4e6..0d62c9dba32f931233ccf283dab89b3191a2ff8a 100644
--- a/aleksis/core/frontend/components/app/customMenu.graphql
+++ b/aleksis/core/frontend/components/app/customMenu.graphql
@@ -1,4 +1,4 @@
-query ($name: String!) {
+query customMenu($name: String!) {
   customMenuByName(name: $name) {
     name
     items {
diff --git a/aleksis/core/frontend/components/app/dynamicRoutes.graphql b/aleksis/core/frontend/components/app/dynamicRoutes.graphql
index 49c208b729f4185b0053e8bfbff13fd3ca8c78f5..433a1ca753a8d16ce1281575301bb700329a6d46 100644
--- a/aleksis/core/frontend/components/app/dynamicRoutes.graphql
+++ b/aleksis/core/frontend/components/app/dynamicRoutes.graphql
@@ -1,4 +1,4 @@
-{
+query dynamicRoutes {
   dynamicRoutes {
     parentRouteName
 
diff --git a/aleksis/core/frontend/components/app/messages.graphql b/aleksis/core/frontend/components/app/messages.graphql
index 96c09c62c90962b103fae88dc53bc4c96e1a2b49..ffbdd6e40ad308d30a13e561efcbd5dc4e747941 100644
--- a/aleksis/core/frontend/components/app/messages.graphql
+++ b/aleksis/core/frontend/components/app/messages.graphql
@@ -1,4 +1,4 @@
-{
+query messages {
   messages {
     tags
     message
diff --git a/aleksis/core/frontend/components/app/ping.graphql b/aleksis/core/frontend/components/app/ping.graphql
index 1775ef4a61beab635ff199bd454e328cd02c3304..83101ecfc0a7c223607a9ff9e24504d5c77a6d1c 100644
--- a/aleksis/core/frontend/components/app/ping.graphql
+++ b/aleksis/core/frontend/components/app/ping.graphql
@@ -1,3 +1,3 @@
-query Ping($payload: String) {
+query ping($payload: String) {
   ping(payload: $payload)
 }
diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql
index b8ec991bda2b1b689c97f2fb339dafce9d0e1175..eec69756e5857ecc4fa3ec6d8c0b19aa6afe49f2 100644
--- a/aleksis/core/frontend/components/app/systemProperties.graphql
+++ b/aleksis/core/frontend/components/app/systemProperties.graphql
@@ -1,4 +1,4 @@
-{
+query systemProperties {
   systemProperties {
     availableLanguages {
       code
diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql
index 68ab08b05d479b8149b3772406a81dd0673c3169..b143296677bd0d96453f98a2adb1378a4eff17f4 100644
--- a/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql
+++ b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql
@@ -1,4 +1,4 @@
-{
+query oauthAccessTokens {
   accessTokens: oauthAccessTokens {
     id
     created
diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql
index 232f231cc531453dc47d14f4715985772ed3d3fb..9c1063d0686553cb07beba2beff1ad5b15044455 100644
--- a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql
+++ b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql
@@ -1,4 +1,4 @@
-mutation ($ids: [ID]!) {
+mutation revokeOauthTokens($ids: [ID]!) {
   revokeOauthTokens(ids: $ids) {
     revokationCount
   }
diff --git a/aleksis/core/frontend/components/calendar/calendarFeeds.graphql b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql
index 917cf386965d820bc634583a1af6339e8500c0cc..f49deafb6a0f2bfd45cc8fa762fde98f81eb680d 100644
--- a/aleksis/core/frontend/components/calendar/calendarFeeds.graphql
+++ b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql
@@ -1,4 +1,4 @@
-query {
+query calendarFeeds {
   calendar {
     allFeedsUrl
     calendarFeeds {
diff --git a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
index 633f0791c3e00f0c82886779366704086871c484..3a2c8a72ad9aff66d7d6f8c8f88f628892f546e1 100644
--- a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
+++ b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
@@ -1,4 +1,4 @@
-mutation ($calendars: [String]!) {
+mutation setCalendarStatus($calendars: [String]!) {
   setCalendarStatus(calendars: $calendars) {
     ok
   }
diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql
index 557e33517d4f3536e043ba0e64cb8c3f622741d2..985c12c713ab480926e67ff27355b8f1eec43a1b 100644
--- a/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql
+++ b/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql
@@ -1,4 +1,4 @@
-query ($taskId: String!) {
+query celeryProgress($taskId: String!) {
   celeryProgressByTaskId(taskId: $taskId) {
     state
     success
diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql
index 5cae8f3baa46b14b4cf1031dc248d2dd7757b576..17392bb390a31e287dfc9d8b2be26b6dfe44587a 100644
--- a/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql
+++ b/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql
@@ -1,4 +1,4 @@
-{
+query celeryProgressByUser {
   celeryProgressByUser {
     state
     success
diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql
index b3fedc916e6a3851677f0fe7a3c322c9311a33e4..4556d69113e6970b33254eb36bf8dd401c245e70 100644
--- a/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql
+++ b/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql
@@ -1,4 +1,4 @@
-mutation ($taskId: String!) {
+mutation celeryProgressFetched($taskId: String!) {
   celeryProgressFetched(taskId: $taskId) {
     celeryProgress {
       state
diff --git a/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue b/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue
index e67168a8e23eb87f0f4cb9c1a34227f1fb24ee5a..6322e58fcc8e563af5edefbaa2d6f5947fc77a46 100644
--- a/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue
+++ b/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue
@@ -59,9 +59,12 @@ import { DateTime } from "luxon";
         ref="days"
       >
         <v-list-item-content>
-          <v-subheader class="text-h6">{{
-            $d(date, "dateWithWeekday")
-          }}</v-subheader>
+          <v-subheader
+            class="text-h5 px-2 hover-hash"
+            @click="gotoDate(date.toISODate())"
+          >
+            {{ $d(date, "dateWithWeekday") }}
+          </v-subheader>
           <v-list max-width="100%" class="pt-0 mt-n1">
             <v-list-item
               class="px-1"
@@ -126,6 +129,13 @@ import { DateTime } from "luxon";
           }}
         </CRUDIteratorEmptyMessage>
       </slot>
+
+      <date-select-footer
+        :value="currentDate"
+        @input="gotoDate"
+        @prev="gotoPrev"
+        @next="gotoNext"
+      />
     </template>
 
     <template #no-results>
@@ -577,12 +587,11 @@ export default {
         this.gotoDate(next.toISODate());
       }
     },
-    focus(element, how) {
+    focus(element, how = "smooth") {
       // Helper function used to scroll to day group.
-      element.$el.scrollIntoView({
-        behavior: how,
-        block: "start",
-        inline: "nearest",
+      this.$vuetify.goTo(element, {
+        duration: how === "instant" ? 0 : 400,
+        offset: this.topMargin,
       });
     },
   },
@@ -601,3 +610,11 @@ export default {
   },
 };
 </script>
+
+<style scoped>
+.hover-hash:hover::before {
+  position: absolute;
+  left: -1ch;
+  content: "#";
+}
+</style>
diff --git a/aleksis/core/frontend/components/notifications/markNotificationRead.graphql b/aleksis/core/frontend/components/notifications/markNotificationRead.graphql
index 8cc7bed4325857b3407701900a356150d3614b68..689d3c1e44172efabd8c62b03488dd5e71f6d695 100644
--- a/aleksis/core/frontend/components/notifications/markNotificationRead.graphql
+++ b/aleksis/core/frontend/components/notifications/markNotificationRead.graphql
@@ -1,4 +1,4 @@
-mutation ($id: ID!) {
+mutation markNotificationRead($id: ID!) {
   markNotificationRead(id: $id) {
     notification {
       id
diff --git a/aleksis/core/frontend/components/notifications/myNotifications.graphql b/aleksis/core/frontend/components/notifications/myNotifications.graphql
index 5c430731b0e3993e2c4b0872c4a44b5d8639a0bf..9fcddc77f1e1f50dac823d308851977bf5cc08b1 100644
--- a/aleksis/core/frontend/components/notifications/myNotifications.graphql
+++ b/aleksis/core/frontend/components/notifications/myNotifications.graphql
@@ -1,4 +1,4 @@
-{
+query myNotifications {
   myNotifications: whoAmI {
     id
     person {
diff --git a/aleksis/core/frontend/components/pdf/pdf.graphql b/aleksis/core/frontend/components/pdf/pdf.graphql
index aac3228d75c3c77ab131500b5e0d5e2ae1b8f503..0a5f7eb34e4a6e482615131870bc1fbd3210ef98 100644
--- a/aleksis/core/frontend/components/pdf/pdf.graphql
+++ b/aleksis/core/frontend/components/pdf/pdf.graphql
@@ -1,4 +1,4 @@
-query ($id: ID!) {
+query pdf($id: ID!) {
   pdf: pdfById(id: $id) {
     file {
       url
diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue
index afa3b04efd549910a7197a44af3d859af04935f8..c9244a80b22a8c8e10b74b79f27e6219408f28bb 100644
--- a/aleksis/core/frontend/components/person/PersonActions.vue
+++ b/aleksis/core/frontend/components/person/PersonActions.vue
@@ -101,7 +101,7 @@
 </template>
 
 <script>
-import { actions, deletePersons } from "./personActions.graphql";
+import { personActions, deletePersons } from "./personActions.graphql";
 import DeleteDialog from "../generic/dialogs/DeleteDialog.vue";
 
 export default {
@@ -115,7 +115,7 @@ export default {
   },
   apollo: {
     person: {
-      query: actions,
+      query: personActions,
       variables() {
         return {
           id: this.id,
diff --git a/aleksis/core/frontend/components/person/personActions.graphql b/aleksis/core/frontend/components/person/personActions.graphql
index ebfbb0fcd137be8a35114f60a39d99da1be1436e..1936545144501d6ab9565496a91f7ff30cc9f8c9 100644
--- a/aleksis/core/frontend/components/person/personActions.graphql
+++ b/aleksis/core/frontend/components/person/personActions.graphql
@@ -1,4 +1,4 @@
-query actions($id: ID!) {
+query personActions($id: ID!) {
   person: personById(id: $id) {
     id
     userid
diff --git a/aleksis/core/frontend/components/room/RoomChip.vue b/aleksis/core/frontend/components/room/RoomChip.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1e14588742a276ca1a57476fbc2558e726ba482f
--- /dev/null
+++ b/aleksis/core/frontend/components/room/RoomChip.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+  name: "RoomChip",
+  props: {
+    room: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
+
+<template>
+  <v-tooltip bottom>
+    <template v-slot:activator="{ on, attrs }">
+      <v-chip v-bind="{ ...attrs, ...$attrs }" v-on="{ ...on, ...$listeners }">
+        <v-avatar>
+          <v-icon> mdi-door </v-icon>
+        </v-avatar>
+        {{ room.shortName }}
+      </v-chip>
+    </template>
+    <span>{{ room.name }}</span>
+  </v-tooltip>
+</template>
diff --git a/aleksis/core/frontend/components/two_factor/twoFactor.graphql b/aleksis/core/frontend/components/two_factor/twoFactor.graphql
index 431215ed1840f9ad06e61e1c8c63cf6fe0af35b7..bd0837e2437a1708739efb2f1554af86825d9649 100644
--- a/aleksis/core/frontend/components/two_factor/twoFactor.graphql
+++ b/aleksis/core/frontend/components/two_factor/twoFactor.graphql
@@ -1,4 +1,4 @@
-{
+query twoFactor {
   twoFactor {
     activated
     backupTokensCount
diff --git a/aleksis/core/frontend/messages/ar.json b/aleksis/core/frontend/messages/ar.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/aleksis/core/frontend/messages/ar.json
@@ -0,0 +1 @@
+{}
diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json
index 14cb823c9d4b773a0330ed422772fd1c5e0ca4f1..2a45001a603064423ddb81fceb78843f2d11598a 100644
--- a/aleksis/core/frontend/messages/de.json
+++ b/aleksis/core/frontend/messages/de.json
@@ -230,25 +230,43 @@
     "snackbar_success_message": "Der Vorgang wurde erfolgreich beendet."
   },
   "group": {
+    "avatar": "Avatar",
     "child_groups": "Kind-Gruppen",
     "child_groups_n": "Keine Kindgruppen | {n} Kindgruppe | {n} Kindgruppen",
+    "confirm_delete": "Möchten Sie diese Gruppe wirklich löschen?",
     "group_type": {
+      "additional_attributes": "Zusätzliche Attribute",
+      "allowed_information": {
+        "address": "Adresse",
+        "avatar": "Avatar",
+        "contact_details": "Kontaktdetails",
+        "groups": "Gruppen",
+        "personal_details": "Persönliche Details",
+        "photo": "Foto"
+      },
       "create": "Gruppentyp erstellen",
       "description": "Beschreibung",
       "menu_title": "Gruppentypen",
       "name": "Name",
       "no_group_type": "Kein Gruppentyp",
+      "owners_can_see_groups": "Besitzer von Gruppen mit diesem Gruppentyp können die Gruppen sehen",
+      "owners_can_see_members": "Besitzer von Gruppen mit diesem Gruppentyp können die Gruppenmitglieder sehen",
+      "owners_can_see_members_allowed_information": "Informationen, die Besitzer von Gruppen mit diesem Gruppentyp über die Gruppenmitglieder sehen können",
+      "owners_can_see_members_including": "(einschließlich {allowedInformation})",
       "title": "Gruppentyp",
       "title_plural": "Gruppentypen"
     },
     "groups_and_child_groups": "Gruppen und Kindgruppen",
+    "member_of_n": "Keine Gruppenmitgliedschaften | Mitglied in einer Gruppe | Mitglied in {n} Gruppen",
     "menu_title": "Gruppen",
+    "name": "Name",
+    "no_groups": "Keine Gruppen",
+    "owner_of_n": "Keine Gruppeneigentümerschaften | Besitzt eine Gruppe | Besitzt {n} Gruppen",
     "ownership": "Gruppen-Eigentümerschaft",
     "parent_groups": "Übergeordnete Gruppen",
     "parent_groups_n": "Keine übergeordneten Gruppen | {n} übergeordnete Gruppe | {n} übergeordnete Gruppen",
-    "member_of_n": "Keine Gruppenmitgliedschaften | Mitglied in einer Gruppe | Mitglied in {n} Gruppen",
-    "owner_of_n": "Keine Gruppeneigentümerschaften | Besitzt eine Gruppe | Besitzt {n} Gruppen",
     "properties": "Eigenschaften",
+    "school_term": "Schuljahr",
     "short_name": "Kurzname",
     "statistics": {
       "age_average": "Durchschnittsalter",
@@ -284,6 +302,7 @@
     "error_404": "404",
     "offline_notification": "Sie sind offline. Einige Funktionen werden nicht funktionieren und einige Daten werden nicht aktuell sein.",
     "page_not_found": "Die aufgerufene Seite oder Ressource konnte nicht gefunden werden.",
+    "service_unavailable": "Der Server ist aktuell im Wartungsmodus und daher temporär nicht erreichbar.",
     "snackbar_error_message": "Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut."
   },
   "notifications": {
@@ -399,30 +418,30 @@
     "title_plural": "Räume"
   },
   "school_term": {
+    "active_school_term": {
+      "select_action": "Aktuelles auswählen",
+      "subtitle": "Die Auswahl wird auf allen Seiten in AlekSIS berücksichtigt.",
+      "title": "Aktives Schuljahr",
+      "warning": "Hinweis: Sie sehen aktuell Daten aus einem anderen Schuljahr ({termName}). Informationen können daher veraltet sein und entsprechen möglicherweise nicht dem aktuellen Stand."
+    },
     "after": "Endet nach",
     "before": "Beginnt vor",
     "create_school_term": "Schuljahr erstellen",
+    "current": "Aktuell",
     "date_end": "Enddatum",
     "date_start": "Startdatum",
     "menu_title": "Schuljahre",
     "name": "Name",
     "title": "Schuljahr",
-    "title_plural": "Schuljahre",
-    "current": "Aktuell",
-    "active_school_term": {
-      "title": "Aktives Schuljahr",
-      "subtitle": "Die Auswahl wird auf allen Seiten in AlekSIS berücksichtigt.",
-      "warning": "Hinweis: Sie sehen aktuell Daten aus einem anderen Schuljahr ({termName}). Informationen können daher veraltet sein und entsprechen möglicherweise nicht dem aktuellen Stand.",
-      "select_action": "Aktuelles auswählen"
-    }
+    "title_plural": "Schuljahre"
   },
   "selection": {
     "num_items_selected": "Keine Objekte ausgewählt | 1 Objekt ausgewählt | {n} Objekte ausgewählt"
   },
   "service_worker": {
     "new_version_available": {
-      "header": "Neue Version verfügbar",
-      "body": "AlekSIS® wurde im Hintergrund aktualisiert. Um {instance} weiterhin zu verwenden, muss die Aktualisierung jetzt eingespielt werden."
+      "body": "AlekSIS® wurde im Hintergrund aktualisiert. Um {instance} weiterhin zu verwenden, muss die Aktualisierung jetzt eingespielt werden.",
+      "header": "Neue Version verfügbar"
     },
     "update": "Fertigstellen"
   },
diff --git a/aleksis/core/frontend/messages/fr.json b/aleksis/core/frontend/messages/fr.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/aleksis/core/frontend/messages/fr.json
@@ -0,0 +1 @@
+{}
diff --git a/aleksis/core/frontend/messages/la.json b/aleksis/core/frontend/messages/la.json
new file mode 100644
index 0000000000000000000000000000000000000000..5c0ddcc9576185d24aadc606bcdb0d244b138d5e
--- /dev/null
+++ b/aleksis/core/frontend/messages/la.json
@@ -0,0 +1,72 @@
+{
+  "accounts": {
+    "login": {
+      "menu_title": "nomen profiteri"
+    },
+    "logout": {
+      "menu_title": "nomen retractare"
+    }
+  },
+  "actions": {
+    "search": "Quaerere"
+  },
+  "announcement": {
+    "menu_title": "Nuntii",
+    "title_plural": "Nuntii"
+  },
+  "forms": {
+    "date_time": {
+      "date": "dies",
+      "time": "tempus"
+    },
+    "labels": {
+      "persons": "personae"
+    }
+  },
+  "group": {
+    "group_type": {
+      "allowed_information": {
+        "groups": "Greges",
+        "photo": "Photographia"
+      },
+      "description": "Descriptio",
+      "name": "Nomen"
+    },
+    "menu_title": "Greges",
+    "name": "Nomen",
+    "short_name": "Breve nomen",
+    "title": "Grex",
+    "title_plural": "Greges"
+  },
+  "holidays": {
+    "holiday_name": "Nomen"
+  },
+  "notifications": {
+    "notifications": "Nuntii"
+  },
+  "person": {
+    "birth_date": "Dies natalis",
+    "guardians": "Parentes",
+    "home": "Numerus telephoni domi",
+    "menu_title": "personae",
+    "mobile": "Numerus telephoni mobilis",
+    "name": "Nomen",
+    "page_title": "Persona",
+    "sex": {
+      "field": "Genus"
+    },
+    "sex_description": "Genus",
+    "title": "Persona",
+    "title_plural": "personae"
+  },
+  "personal_events": {
+    "description": "Descriptio",
+    "title": "Titulus"
+  },
+  "rooms": {
+    "name": "Nomen"
+  },
+  "school_term": {
+    "name": "Nomen"
+  }
+}
diff --git a/aleksis/core/frontend/messages/nb_NO.json b/aleksis/core/frontend/messages/nb_NO.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/aleksis/core/frontend/messages/nb_NO.json
@@ -0,0 +1 @@
+{}
diff --git a/aleksis/core/frontend/messages/ru.json b/aleksis/core/frontend/messages/ru.json
index 55b38b268ae7e64e1769ee0b32edd92782eb52a6..50ab11618d55a474ff37afc585066748f33aede4 100644
--- a/aleksis/core/frontend/messages/ru.json
+++ b/aleksis/core/frontend/messages/ru.json
@@ -194,6 +194,7 @@
     },
     "groups_and_child_groups": "Группы и дочерние группы",
     "menu_title": "Группы",
+    "name": "Имя",
     "ownership": "Владельцы группы",
     "title": "Группа",
     "title_plural": "Группы"
diff --git a/aleksis/core/frontend/messages/tr.json b/aleksis/core/frontend/messages/tr.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/aleksis/core/frontend/messages/tr.json
@@ -0,0 +1 @@
+{}
diff --git a/aleksis/core/frontend/messages/uk.json b/aleksis/core/frontend/messages/uk.json
index 49d74190628e145aa9ab324c5eb0ccf2e13f66e0..3d0aa93fe3acf06181bf4ffb4a5be4d5e2f0e827 100644
--- a/aleksis/core/frontend/messages/uk.json
+++ b/aleksis/core/frontend/messages/uk.json
@@ -187,6 +187,12 @@
   },
   "group": {
     "group_type": {
+      "allowed_information": {
+        "address": "Адреса",
+        "contact_details": "Контактні дані",
+        "groups": "Групи",
+        "photo": "Фото"
+      },
       "description": "Опис",
       "menu_title": "Типи груп",
       "name": "Назва",
@@ -195,6 +201,7 @@
     },
     "groups_and_child_groups": "Групи та підлеглі групи",
     "menu_title": "Групи",
+    "name": "Назва",
     "ownership": "Власність групи",
     "short_name": "Коротка назва",
     "title": "Група",
diff --git a/aleksis/core/migrations/0069_add_calendar_alarm.py b/aleksis/core/migrations/0069_add_calendar_alarm.py
new file mode 100644
index 0000000000000000000000000000000000000000..1ecbdbe511af95fa2244e06986131b876d0e00f1
--- /dev/null
+++ b/aleksis/core/migrations/0069_add_calendar_alarm.py
@@ -0,0 +1,53 @@
+# Generated by Django 5.0.6 on 2024-07-12 11:34
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('core', '0068_calendar_event_amends_unique_constraints'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CalendarAlarm',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('action', models.CharField(choices=[('audio', 'Audio'), ('display', 'Display'), ('email', 'Email'), ('procedure', 'Procedure')], default='display', max_length=10, verbose_name='Action')),
+                ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alarms', to='core.calendarevent', verbose_name='Event')),
+                ('send_notifications', models.BooleanField(default=False, verbose_name='Send notifications')),
+                ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')),
+            ],
+            options={
+                'verbose_name': 'Calendar alarm',
+                'verbose_name_plural': 'Calendar alarms',
+            },
+        ),
+        migrations.CreateModel(
+            name='PersonalEventAlarm',
+            fields=[
+                ('calendaralarm_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.calendaralarm')),
+            ],
+            options={
+                'verbose_name': 'Personal event alarm',
+                'verbose_name_plural': 'Personal event alarms',
+            },
+            bases=('core.calendaralarm',),
+        ),
+        migrations.AddField(
+            model_name='notification',
+            name='calendar_alarm',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='core.calendaralarm', verbose_name='Calendar alarm'),
+        ),
+        migrations.AddConstraint(
+            model_name='notification',
+            constraint=models.UniqueConstraint(condition=models.Q(('calendar_alarm__isnull', False)), fields=('calendar_alarm', 'recipient'), name='unique_recipient_per_calendar_alarm'),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index e6fa9a21ca68309e91fd55f50c584f529bdc7387..ad6e3a4623c9760e12df793f0edd14c66a0c449c 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -21,7 +21,6 @@ from django.views.generic import CreateView, UpdateView
 from django.views.generic.edit import DeleteView, ModelFormMixin
 
 import reversion
-from django_ical.feedgenerator import ITEM_ELEMENT_FIELD_MAP
 from dynamic_preferences.settings import preferences_settings
 from dynamic_preferences.types import FilePreference
 from guardian.admin import GuardedModelAdmin
@@ -39,7 +38,7 @@ from aleksis.core.managers import (
     SchoolTermRelatedQuerySet,
 )
 
-from .util.core_helpers import ExtendedICal20Feed
+from .util.core_helpers import EXTENDED_ITEM_ELEMENT_FIELD_MAP, ExtendedICal20Feed
 
 if TYPE_CHECKING:
     from .models import Person
@@ -725,7 +724,7 @@ class CalendarEventMixin(RegistryObject):
     @classmethod
     def get_event_field_names(cls) -> list[str]:
         """Return the names of the fields to be used for the feed."""
-        return [field_map[0] for field_map in ITEM_ELEMENT_FIELD_MAP]
+        return [field_map[0] for field_map in EXTENDED_ITEM_ELEMENT_FIELD_MAP]
 
     @classmethod
     def get_event_field_value(
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 2c61c6820d938bf1cd0c707010b0e2acaf1c7462..6a813fa84be2a47dcfe8ce4bfe73f0d6376c1f7f 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -36,7 +36,7 @@ from django_ical.utils import build_rrule_from_recurrences_rrule, build_rrule_fr
 from django_pg_rrule.models import RecurrenceModel
 from dynamic_preferences.models import PerInstancePreferenceModel
 from guardian.shortcuts import get_objects_for_user
-from icalendar import vCalAddress, vText
+from icalendar import Alarm, vCalAddress, vText
 from icalendar.prop import vRecur
 from invitations import signals
 from invitations.base_invitation import AbstractBaseInvitation
@@ -104,6 +104,17 @@ FIELD_CHOICES = (
     ("URLField", _("URL / Link")),
 )
 
+CALENDAR_ALARM_FIELD_MAP = (
+    ("action", "action"),
+    ("trigger", "trigger"),
+    ("duration", "duration"),
+    ("repeat", "repeat"),
+    ("attach", "attach"),
+    ("description", "description"),
+    ("summary", "summary"),
+    ("attendee", "attendee"),
+)
+
 
 class SchoolTerm(ExtensibleModel):
     """School term model.
@@ -714,6 +725,15 @@ class Notification(ExtensibleModel, TimeStampedModel):
     read = models.BooleanField(default=False, verbose_name=_("Read"))
     sent = models.BooleanField(default=False, verbose_name=_("Sent"))
 
+    calendar_alarm = models.ForeignKey(
+        "CalendarAlarm",
+        related_name="notifications",
+        verbose_name=_("Calendar alarm"),
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+    )
+
     def __str__(self):
         return str(self.title)
 
@@ -731,6 +751,13 @@ class Notification(ExtensibleModel, TimeStampedModel):
     class Meta:
         verbose_name = _("Notification")
         verbose_name_plural = _("Notifications")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["calendar_alarm", "recipient"],
+                condition=Q(calendar_alarm__isnull=False),
+                name="unique_recipient_per_calendar_alarm",
+            ),
+        ]
 
 
 class AnnouncementQuerySet(models.QuerySet):
@@ -1609,6 +1636,15 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo
         """Return the reference object itself."""
         return reference_object
 
+    @classmethod
+    def value_valarm(
+        cls, reference_object: "CalendarEvent", request: HttpRequest | None = None
+    ) -> list[Alarm]:
+        """Return all CalendarAlarms associated with the event, converted into Alarm objects."""
+        return [
+            calendar_alarm.get_alarm(request) for calendar_alarm in reference_object.alarms.all()
+        ]
+
     @classmethod
     def get_objects(
         cls,
@@ -1944,3 +1980,162 @@ class Organisation(ExtensibleModel):
     class Meta:
         verbose_name = _("Organisation")
         verbose_name_plural = _("Organisations")
+
+
+class CalendarAlarm(ExtensiblePolymorphicModel):
+    """An alarm bound to a CalendarEvent.
+
+    To make use of this model, you need to inherit from this model.
+    Every subclass of this model represents a certain group of alarms.
+
+    Every `value_*` method from can be implemented to provide additional data
+    (either static or dynamic). Some like `value_action` are pre-implemented
+    in this model. Some like `value_action` are optional and need to be implemented
+    in a model inheriting from this model. The following iCal attributes are supported:
+
+    action, trigger, duration, repeat, attach, description, summary, attendee
+
+    Whether the implementation of some methods is required depends on the action
+    type of the alarm. See the iCalendar RFC 5545 documentation for more information.
+    """
+
+    ACTION_CHOICES = [
+        ("audio", _("Audio")),
+        ("display", _("Display")),
+        ("email", _("Email")),
+        ("procedure", _("Procedure")),
+    ]
+
+    event = models.ForeignKey(
+        CalendarEvent, on_delete=models.CASCADE, related_name="alarms", verbose_name=_("Event")
+    )
+
+    action = models.CharField(
+        verbose_name=_("Action"), max_length=10, default="display", choices=ACTION_CHOICES
+    )
+
+    send_notifications = models.BooleanField(verbose_name=_("Send notifications"), default=False)
+
+    def value_action(self, request: HttpRequest | None = None) -> str:
+        """Return the action type of the calendar alarm.
+
+        The action type determines in which way the alarm shall be communicated to the user.
+        """
+        return self.action
+
+    def value_trigger(self, request: HttpRequest | None = None) -> Union[datetime, timedelta]:
+        """Return the trigger of the calendar alarm.
+
+        The trigger can be either a time delta value indicating at which time relative to the
+        reference event the alarm shall be triggered or a datetime value indicating an absolute
+        time at which this shall happen.
+        """
+        raise NotImplementedError()
+
+    def value_attach(self, request: HttpRequest | None = None) -> Optional[str]:
+        """Return the attachment of the calendar alarm."""
+        if self.value_action(request) == "procedure":
+            raise NotImplementedError()
+        return None
+
+    def value_description(self, request: HttpRequest | None = None) -> Optional[str]:
+        """Return the description of the calendar alarm."""
+        if self.value_action(request) == "display" or self.value_action(request) == "email":
+            raise NotImplementedError()
+        return None
+
+    def value_summary(self, request: HttpRequest | None = None) -> Optional[str]:
+        """Return the summary of the calendar alarm."""
+        if self.value_action(request) == "email":
+            raise NotImplementedError()
+        return None
+
+    def value_attendee(self, request: HttpRequest | None = None) -> Optional[str]:
+        """Return the attendees of the calendar alarm."""
+        if self.value_action(request) == "email":
+            raise NotImplementedError()
+        return None
+
+    def value_notification_recipients(self, request: HttpRequest | None = None) -> list[Person]:
+        """Return the recipients of the notification linked to the calendar alarm."""
+        raise NotImplementedError()
+
+    def value_notification_sender(self, request: HttpRequest | None = None) -> str:
+        """Return the sender of the notification linked to the calendar alarm."""
+        raise NotImplementedError()
+
+    def value_notification_title(self, request: HttpRequest | None = None) -> str:
+        """Return the title of the notification linked to the calendar alarm."""
+        raise NotImplementedError()
+
+    def value_notification_description(self, request: HttpRequest | None = None) -> str:
+        """Return the description of the notification linked to the calendar alarm."""
+        return self.value_description(request)
+
+    def value_notification_send_at(self, request: HttpRequest | None = None) -> datetime:
+        """Return the absolute time to send the notification linked to the calendar alarm."""
+        if isinstance(self.value_trigger(request), datetime):
+            return self.value_trigger(request)
+        elif isinstance(self.value_trigger(request), timedelta):
+            return self.event.datetime_start - self.value_trigger(request)
+
+    def get_alarm(self, request: Optional[HttpRequest] = None) -> Alarm:
+        alarm = Alarm()
+        for field in CALENDAR_ALARM_FIELD_MAP:
+            method_name = f"value_{field[0]}"
+            if hasattr(self, method_name) and callable(getattr(self, method_name)):
+                value = getattr(self, method_name)(request=request)
+                if value:
+                    alarm.add(field[1], value)
+        return alarm
+
+    def update_or_create_notifications(
+        self, request: Optional[HttpRequest] = None
+    ) -> Optional[list[Notification]]:
+        """Update or create notifications for this calendar alarm (and send them)."""
+        notifications = []
+
+        default_dict = {}
+        for field_name in [field.name for field in Notification._meta.get_fields()]:
+            method_name = f"value_notification_{field_name}"
+            if hasattr(self, method_name) and callable(getattr(self, method_name)):
+                value = getattr(self, method_name)(request=request)
+                if value:
+                    default_dict[field_name] = value
+
+        for recipient in self.value_notification_recipients(request):
+            try:
+                notification = Notification.objects.get(calendar_alarm=self, recipient=recipient)
+                for key, value in default_dict.items():
+                    setattr(notification, key, value)
+                notification.save()
+            except Notification.DoesNotExist:
+                new_values = {"calendar_alarm": self, "recipient": recipient}
+                new_values.update(default_dict)
+                notification = Notification(**new_values)
+                notification.save()
+
+            # Using django's update_or_create creates id collisions, for some reason.
+            notifications.append(notification)
+
+        return notifications
+
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+
+        if self.send_notifications:
+            self.update_or_create_notifications()
+
+    class Meta:
+        verbose_name = _("Calendar alarm")
+        verbose_name_plural = _("Calendar alarms")
+
+
+class PersonalEventAlarm(CalendarAlarm):
+    def value_description(self, request: HttpRequest | None = None) -> Optional[str]:
+        """Return the description of the personal event alarm."""
+        return self.event.description
+
+    class Meta:
+        verbose_name = _("Personal event alarm")
+        verbose_name_plural = _("Personal event alarms")
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 6fdb6de794a30234cfe6817e53d45f30b4f61bad..a8f35f837c58b62cd0cd5bc111958f7f4aaa8a10 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -614,6 +614,8 @@ YARN_INSTALLED_APPS = [
     "rrule",
     "luxon@^3.4.3",
     "apollo-upload-client@^18.0.1",
+    "@vitejs/plugin-legacy@^6.0.0",
+    "terser@^5.37.0",
 ]
 
 merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
diff --git a/aleksis/core/templates/core/vue_index.html b/aleksis/core/templates/core/vue_index.html
index 8a000ad1d5e8147183207b00fe4ea5d4cecb4844..19e192432ac050d51ca23ed3a13bead25099a346 100644
--- a/aleksis/core/templates/core/vue_index.html
+++ b/aleksis/core/templates/core/vue_index.html
@@ -34,6 +34,8 @@
     {% block no_frontend %}
       {% if not need_maintenance_response %}
         {% vite_asset 'aleksis/core/frontend/index.js' %}
+        {% vite_legacy_asset 'aleksis/core/frontend/index-legacy.js' %}
+        {% vite_legacy_polyfills nomodule=False %}
       {% endif %}
     {% endblock no_frontend %}
   </body>
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index ef47395aa20f338d91f51dbfea3c58703144ebd0..3a6aa841a4d26823e24c18a34b6e9ca50a4bfff7 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -476,8 +476,8 @@ def get_ip(*args, **kwargs):
     return get_client_ip(*args, **kwargs)[0]
 
 
-feedgenerator.FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),)
-feedgenerator.ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + (
+EXTENDED_FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),)
+EXTENDED_ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + (
     ("color", "color"),
     ("meta", "x-meta"),
     ("reference_object", "reference_object"),
@@ -496,7 +496,7 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed):
         cal.add("calscale", "GREGORIAN")
         cal.add("prodid", "-//AlekSIS//AlekSIS//EN")
 
-        for ifield, efield in feedgenerator.FEED_FIELD_MAP:
+        for ifield, efield in EXTENDED_FEED_FIELD_MAP:
             val = self.feed.get(ifield)
             if val is not None:
                 cal.add(efield, val)
@@ -518,7 +518,7 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed):
             component_type = item.get("component_type")
             element = Todo() if component_type == "todo" else Event()
 
-            for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP:
+            for ifield, efield in EXTENDED_ITEM_ELEMENT_FIELD_MAP:
                 val = item.get(ifield)
                 if val is not None:
                     if ifield == "attendee":
@@ -595,3 +595,4 @@ def filter_active_school_term_by_date(
                 }
             )
         )
+    return qs
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index e9c908c1395a208d6c0471898d4ee758ea4608a8..f308ac0afee1ca3b95c4796d074919eafedbb61f 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -134,6 +134,9 @@ from .util.core_helpers import (
 from .util.forms import PreferenceLayout
 from .util.pdf import render_pdf
 
+if settings.SENTRY_ENABLED:
+    import sentry_sdk
+
 
 class LogoView(View):
     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
@@ -1265,8 +1268,17 @@ class TwoFactorLoginView(two_factor_views.LoginView):
 class LoggingGraphQLView(FileUploadGraphQLView):
     """GraphQL view that raises unknown exceptions instead of blindly catching them."""
 
-    def execute_graphql_request(self, *args, **kwargs):
-        result = super().execute_graphql_request(*args, **kwargs)
+    def execute_graphql_request(
+        self, request, data, query, variables, operation_name, show_graphiql=False
+    ):
+        if settings.SENTRY_ENABLED and operation_name:
+            scope = sentry_sdk.get_current_scope()
+            scope.set_transaction_name(operation_name)
+
+        result = super().execute_graphql_request(
+            request, data, query, variables, operation_name, show_graphiql
+        )
+
         errors = result.errors or []
         for error in errors:
             if not isinstance(
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index fb43a46a0a9533a5282f1b3424078791529d3186..c08d285ed2565c56c23c20e07c0ba838c8391f77 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -33,6 +33,7 @@ import topLevelAwait from "vite-plugin-top-level-await";
 import browserslistToEsbuild from "browserslist-to-esbuild";
 const license = require("rollup-plugin-license");
 import SupportedBrowsers from "vite-plugin-browserslist-useragent";
+import legacy from "@vitejs/plugin-legacy";
 
 // Read the hints writen by `aleksis-admin vite`
 const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json"));
@@ -301,6 +302,10 @@ export default defineConfig({
       ignoreMinor: true,
       allowHigherVersions: true,
     }),
+    legacy({
+      targets: browsersList,
+      modernPolyfills: true,
+    }),
     VitePWA({
       injectRegister: "null",
       devOptions: {
diff --git a/conftest.py b/conftest.py
index 2c92d6b9540548ea2c6251381fef809cc1413679..c910cacac58bae2eaaf4a18c4006f12592ddbff1 100644
--- a/conftest.py
+++ b/conftest.py
@@ -33,15 +33,15 @@ def graphql_query(
         header_params = {"headers": headers}
         resp = client.post(
             graphql_url,
-            json.dumps([body]),
+            json.dumps(body),
             content_type="application/json",
             **header_params,
         )
     else:
         resp = client.post(
-            graphql_url, json.dumps([body]), content_type="application/json"
+            graphql_url, json.dumps(body), content_type="application/json"
         )
-    content = json.loads(resp.content)[0]
+    content = json.loads(resp.content)
     return resp, content
 
 
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index 0d35c8f65b5ce21d16d8f306368ea75ac957c92f..f7346c108c0537dc14b7eb76aef5208f7ca9c2db 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -32,7 +32,7 @@ For an installation on a dedicated server, the following prerequisites are neede
  * Valkey (or legacy Redis)
  * uWSGI
  * nginx
- * Python 3.9 or newer
+ * Python 3.10 or newer
  * Node.js 18 or newer
  * Some system dependencies to build Python modules and manage frontend files
  * System locales for all supported languages
diff --git a/pyproject.toml b/pyproject.toml
index c49b40e6eb6a7d1b4ec63fde1b6f6d3a475d9964..0f98ed5790d9d58ccbf63b8ced2d4e7a48a10d40 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -122,7 +122,7 @@ django-iconify = "^0.4"
 customidenticon = "^0.1.5"
 graphene-django = ">=3.0.0, <=3.2.2"
 selenium = "^4.4.3"
-django-vite = "^3.0.0"
+django-vite = "^3.0.6"
 graphene-django-cud = "^0.13.0"
 django-ical = "^1.9.2"
 django-recurrence = "^1.11.1"