diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 31a7de8c22915f4e4955a8ad7526ddaeabef56e5..ebbc04a158ae63f7ece500b7ac515d3f6b224c56 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,8 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Changes
+=======
 The "managed models" feature is mandatory for all models derived from `ExtensibleModel`
 and requires creating a migration for all downstream models to add the respective
 field.
@@ -17,10 +19,14 @@ Added
 ~~~~~
 
 * Frontend for managing rooms.
+* Global calendar system
+* Calendar for birthdays of persons
+* Holiday model to track information about holidays.
 * [Dev] Components for implementing standard CRUD operations in new frontend.
 * [Dev] Options for filtering and sorting of GraphQL queries at the server.
 * [Dev] Managed models for instances handled by other apps.
 * [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients
+* Generic endpoint for retrieving objects as JSON
 
 Changed
 ~~~~~~~
@@ -34,6 +40,7 @@ Fixed
   in an incomplete AlekSIS frontend app.
 * GraphQL mutations did not return errors in case of exceptions.
 * Rendering of "simple" PDF templates failed when used with S3 storage.
+* Log messages on some loggers did not contain log message
 
 `3.1.2`_ - 2023-07-05
 ---------------------
diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js
index f80eb0ac531c075935acdb2249ec5c1676267891..062478e5a2d28ee807406fd0d6828a79f447dac4 100644
--- a/aleksis/core/frontend/app/vuetify.js
+++ b/aleksis/core/frontend/app/vuetify.js
@@ -28,7 +28,7 @@ const vuetifyOpts = {
       filterEmpty: "mdi-filter-outline",
       filterSet: "mdi-filter",
       send: "mdi-send-outline",
-      holidays: "mdi-calendar-weekend-outline"
+      holidays: "mdi-calendar-weekend-outline",
     },
   },
 };
diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
index a9dc64fdb9bfd5713d68d2ebf926b78ad255ea51..6b50ee5ba208b225709e6963ec679e826ceead82 100644
--- a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue
@@ -3,7 +3,9 @@
     v-model="model"
     :close-on-content-click="false"
     :activator="selectedElement"
-    offset-x
+    :offset-x="calendarType !== 'day'"
+    min-width="350px"
+    :offset-y="calendarType === 'day'"
   >
     <v-card min-width="350px" flat>
       <v-toolbar :color="color || selectedEvent.color" dark dense>
@@ -26,13 +28,31 @@
           </v-list-item-icon>
           <v-list-item-content>
             <v-list-item-title>
-              <span v-if="selectedEvent.start !== selectedEvent.end">
+              <span
+                v-if="
+                  selectedEvent.allDay &&
+                  selectedEvent.start.getTime() === selectedEvent.end.getTime()
+                "
+              >
+                {{ $d(selectedEvent.start, "short") }}
+              </span>
+              <span v-else-if="selectedEvent.allDay">
+                {{ $d(selectedEvent.start, "short") }} –
+                {{ $d(selectedEvent.end, "short") }}
+              </span>
+              <span
+                v-else-if="
+                  dateWithoutTime(selectedEvent.start).getTime() ===
+                  dateWithoutTime(selectedEvent.end).getTime()
+                "
+              >
                 {{ $d(selectedEvent.start, "shortDateTime") }} –
-                {{ $d(selectedEvent.end, "shortDateTime") }}
+                {{ $d(selectedEvent.end, "shortTime") }}
               </span>
               <span v-else>
-                {{ $d(selectedEvent.start, "shortDateTime") }}</span
-              >
+                {{ $d(selectedEvent.start, "shortDateTime") }} –
+                {{ $d(selectedEvent.end, "shortDateTime") }}
+              </span>
             </v-list-item-title>
           </v-list-item-content>
         </v-list-item>
@@ -63,5 +83,12 @@ export default {
   name: "BaseCalendarFeedDetails",
   components: { CancelledCalendarStatusChip },
   mixins: [calendarFeedDetailsMixin],
+  methods: {
+    dateWithoutTime(d) {
+      d = new Date(d);
+      d.setHours(0, 0, 0, 0);
+      return d;
+    },
+  },
 };
 </script>
diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
index 205cbb13a1c150555bec1e222244c2b361d31053..1e1d1436897ad17a1875f0bf8a9cf20d20033310 100644
--- a/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
+++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue
@@ -1,7 +1,10 @@
 <template>
   <div
     class="text-truncate"
-    :class="{ 'text-decoration-line-through': event.status === 'CANCELLED', 'mx-1': withPadding }"
+    :class="{
+      'text-decoration-line-through': event.status === 'CANCELLED',
+      'mx-1': withPadding,
+    }"
     :style="{ height: '100%' }"
   >
     <slot name="time" v-bind="$props">
@@ -9,9 +12,10 @@
         v-if="
           calendarType === 'month' && eventParsed.start.hasTime && !withoutTime
         "
-        class="mr-1 font-weight-bold"
-        >{{ eventParsed.start.time }}</span
+        class="mr-1 font-weight-bold ml-1"
       >
+        {{ eventParsed.start.time }}
+      </span>
     </slot>
     <slot name="icon" v-bind="$props">
       <v-icon v-if="icon" x-small color="white" class="mx-1 left">
@@ -31,13 +35,13 @@ import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js";
 export default {
   name: "BaseCalendarFeedEventBar",
   mixins: [calendarFeedEventBarMixin],
-    props: {
-      withPadding: {
-        required: false,
-        type: Boolean,
-        default: true,
+  props: {
+    withPadding: {
+      required: false,
+      type: Boolean,
+      default: true,
     },
-            icon: {
+    icon: {
       required: false,
       type: String,
       default: "",
@@ -47,6 +51,6 @@ export default {
       type: Boolean,
       default: false,
     },
-    }
+  },
 };
 </script>
diff --git a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c3f9c45c424aa73c0178ea15882e4e6783a73e43
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
@@ -0,0 +1,21 @@
+<script>
+export default {
+  name: "CalendarControlBar",
+  emits: ["prev", "next", "today"],
+};
+</script>
+
+<template>
+  <div class="d-flex justify-center">
+    <v-btn icon class="mx-2" @click="$emit('prev')">
+      <v-icon>mdi-chevron-left</v-icon>
+    </v-btn>
+    <v-btn outlined text class="mx-2" @click="$emit('today')">
+      <v-icon left>mdi-calendar-today-outline</v-icon>
+      {{ $t("calendar.today") }}
+    </v-btn>
+    <v-btn icon class="mx-2" @click="$emit('next')">
+      <v-icon>mdi-chevron-right</v-icon>
+    </v-btn>
+  </div>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
index db9d10a0ab8dfc30bcb68862646b1875a050f9a0..7153b8e1e8dce599fc00527d28bc327a713e4ed4 100644
--- a/aleksis/core/frontend/components/calendar/CalendarOverview.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue
@@ -1,7 +1,9 @@
 <template>
   <div class="mt-4 mb-4">
     <v-skeleton-loader
-      v-if="$apollo.queries.calendarFeeds.loading && calendarFeeds.length === 0"
+      v-if="
+        $apollo.queries.calendar.loading && calendar.calendarFeeds.length === 0
+      "
       type="date-picker-options, actions"
     />
     <div v-else>
@@ -12,28 +14,21 @@
         {{ $refs.calendar.title }}
       </h1>
       <v-row align="stretch">
-        <v-col
-          cols="12"
-          sm="4"
-          lg="3"
-          xl="2"
-          align-self="center"
-          class="d-flex justify-center"
-        >
-          <v-btn icon class="mx-2" @click="$refs.calendar.prev()">
-            <v-icon>mdi-chevron-left</v-icon>
-          </v-btn>
-          <v-btn outlined text class="mx-2" @click="calendarFocus = ''">
-            <v-icon left>mdi-calendar-today-outline</v-icon>
-            {{ $t("calendar.today") }}
-          </v-btn>
-          <v-btn icon class="mx-2" @click="$refs.calendar.next()">
-            <v-icon>mdi-chevron-right</v-icon>
-          </v-btn>
+        <!-- Control bar with prev, next and today buttons -->
+        <v-col cols="12" sm="4" lg="3" xl="2" align-self="center">
+          <calendar-control-bar
+            @prev="$refs.calendar.prev()"
+            @next="$refs.calendar.next()"
+            @today="calendarFocus = new Date()"
+          />
         </v-col>
+
+        <!-- Calendar title with current calendar time range -->
         <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center">
           <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1>
         </v-col>
+
+        <!-- Button menu for selecting currently active calendars (only tablets/mobile) -->
         <v-col
           v-if="$vuetify.breakpoint.mdAndDown"
           cols="12"
@@ -47,11 +42,15 @@
           >
             <calendar-select
               v-model="selectedCalendarFeedNames"
-              :calendar-feeds="calendarFeeds"
+              :calendar-feeds="calendar.calendarFeeds"
+              @input="storeActivatedCalendars"
             />
           </button-menu>
         </v-col>
+
         <v-spacer v-if="$vuetify.breakpoint.lgAndUp" />
+
+        <!-- Calendar type select (month, week, day) -->
         <v-col
           cols="12"
           sm="4"
@@ -59,36 +58,43 @@
           align-self="center"
           :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'"
         >
-          <v-btn-toggle dense v-model="currentCalendarType" class="mx-2">
-            <v-btn
-              v-for="calendarType in availableCalendarTypes"
-              :value="calendarType.type"
-              :key="calendarType.type"
-            >
-              <span class="hidden-sm-and-down">{{
-                nameForMenu(calendarType)
-              }}</span>
-              <v-icon :right="$vuetify.breakpoint.mdAndUp">{{
-                "mdi-" + calendarType.icon
-              }}</v-icon>
-            </v-btn>
-          </v-btn-toggle>
+          <calendar-type-select v-model="currentCalendarType" />
         </v-col>
       </v-row>
       <v-row>
         <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2">
-          <v-list flat>
+          <!-- Mini date picker -->
+          <v-date-picker
+            no-title
+            v-model="calendarFocus"
+            :style="{ margin: '0px -8px' }"
+            :first-day-of-week="1"
+          ></v-date-picker>
+
+          <!-- Calendar select (only desktop) -->
+          <v-list flat subheader>
+            <v-subheader>
+              {{ $t("calendar.my_calendars") }}
+            </v-subheader>
             <calendar-select
+              class="mb-4"
               v-model="selectedCalendarFeedNames"
-              :calendar-feeds="calendarFeeds"
+              :calendar-feeds="calendar.calendarFeeds"
+              @input="storeActivatedCalendars"
             />
+            <v-btn depressed block v-if="calendar" :href="calendar.allFeedsUrl">
+              <v-icon left>mdi-download-outline</v-icon>
+              {{ $t("calendar.download_all") }}
+            </v-btn>
           </v-list>
         </v-col>
+
+        <!-- Actual calendar -->
         <v-col lg="9" xl="10">
           <v-sheet height="600">
             <v-expand-transition>
               <v-progress-linear
-                v-if="$apollo.queries.calendarFeeds.loading"
+                v-if="$apollo.queries.calendar.loading"
                 indeterminate
               />
             </v-expand-transition>
@@ -115,11 +121,12 @@
               </template>
             </v-calendar>
             <component
-              v-if="calendarFeeds && selectedEvent"
+              v-if="calendar && calendar.calendarFeeds && selectedEvent"
               :is="detailComponentForFeed(selectedEvent.calendarFeedName)"
               v-model="selectedOpen"
               :selected-element="selectedElement"
               :selected-event="selectedEvent"
+              :calendar-type="currentCalendarType"
             />
           </v-sheet>
         </v-col>
@@ -140,51 +147,46 @@ import {
 } from "aleksisAppImporter";
 
 import gqlCalendarOverview from "./calendarOverview.graphql";
+import gqlSetCalendarStatus from "./setCalendarStatus.graphql";
+import CalendarControlBar from "./CalendarControlBar.vue";
+import CalendarTypeSelect from "./CalendarTypeSelect.vue";
 
 export default {
   name: "CalendarOverview",
   components: {
+    CalendarTypeSelect,
+    CalendarControlBar,
     ButtonMenu,
     CalendarSelect,
   },
   data() {
     return {
       calendarFocus: "",
-      calendarFeeds: [],
+      calendar: {
+        calendarFeeds: [],
+      },
       selectedCalendarFeedNames: [],
       currentCalendarType: "week",
       selectedEvent: {},
       selectedElement: null,
       selectedOpen: false,
-      availableCalendarTypes: [
-        {
-          type: "month",
-          translationKey: "calendar.month",
-          icon: "calendar-month-outline",
-        },
-        {
-          type: "week",
-          translationKey: "calendar.week",
-          icon: "calendar-week-outline",
-        },
-        {
-          type: "day",
-          translationKey: "calendar.day",
-          icon: "calendar-today-outline",
-        },
-      ],
       fetchedDateRange: { start: null, end: null },
     };
   },
   apollo: {
-    calendarFeeds: {
+    calendar: {
       query: gqlCalendarOverview,
       skip: true,
+      result({ data }) {
+        this.selectedCalendarFeedNames = data.calendar.calendarFeeds
+          .filter((c) => c.activated)
+          .map((c) => c.name);
+      },
     },
   },
   computed: {
     events() {
-      return this.calendarFeeds
+      return this.calendar.calendarFeeds
         .filter((c) => this.selectedCalendarFeedNames.includes(c.name))
         .flatMap((cf) =>
           cf.feed.events.map((event) => ({
@@ -201,9 +203,6 @@ export default {
     },
   },
   methods: {
-    nameForMenu: function (item) {
-      return this.$t(item.translationKey);
-    },
     viewDay({ date }) {
       this.calendarFocus = date;
       this.currentCalendarType = "day";
@@ -228,7 +227,7 @@ export default {
     },
     detailComponentForFeed(feedName) {
       if (
-        this.calendarFeeds &&
+        this.calendar.calendarFeeds &&
         feedName &&
         Object.keys(calendarFeedDetailComponents).includes(feedName + "details")
       ) {
@@ -238,7 +237,7 @@ export default {
     },
     eventBarComponentForFeed(feedName) {
       if (
-        this.calendarFeeds &&
+        this.calendar.calendarFeeds &&
         feedName &&
         Object.keys(calendarFeedEventBarComponents).includes(
           feedName + "eventbar"
@@ -251,6 +250,15 @@ export default {
     getColorForEvent(event) {
       return event.color;
     },
+    storeActivatedCalendars() {
+      // Store currently activated calendars in the backend
+      this.$apollo.mutate({
+        mutation: gqlSetCalendarStatus,
+        variables: {
+          calendars: this.selectedCalendarFeedNames,
+        },
+      });
+    },
     fetchMoreCalendarEvents({ start, end }) {
       // Get the start and end dates of the current date range shown in the calendar
       let extendedStart = this.$refs.calendar.getStartOfWeek(start).date;
@@ -259,15 +267,15 @@ export default {
       let olderStart = extendedStart < this.fetchedDateRange.start;
       let youngerEnd = extendedEnd > this.fetchedDateRange.end;
 
-      if (this.calendarFeeds.length === 0) {
+      if (this.calendar.calendarFeeds.length === 0) {
         // No calendar feeds have been fetched yet,
         // so fetch all events in the current date range
 
-        this.$apollo.queries.calendarFeeds.setVariables({
+        this.$apollo.queries.calendar.setVariables({
           start: extendedStart,
           end: extendedEnd,
         });
-        this.$apollo.queries.calendarFeeds.skip = false;
+        this.$apollo.queries.calendar.skip = false;
         this.fetchedDateRange = { start: extendedStart, end: extendedEnd };
       } else if (olderStart || youngerEnd) {
         // Define newly fetched date range
@@ -278,14 +286,14 @@ export default {
         let fetchStart = olderStart ? extendedStart : this.fetchedDateRange.end;
         let fetchEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.start;
 
-        this.$apollo.queries.calendarFeeds.fetchMore({
+        this.$apollo.queries.calendar.fetchMore({
           variables: {
             start: fetchStart,
             end: fetchEnd,
           },
           updateQuery: (previousResult, { fetchMoreResult }) => {
-            let previousCalendarFeeds = previousResult.calendarFeeds;
-            let newCalendarFeeds = fetchMoreResult.calendarFeeds;
+            let previousCalendarFeeds = previousResult.calendar.calendarFeeds;
+            let newCalendarFeeds = fetchMoreResult.calendar.calendarFeeds;
 
             previousCalendarFeeds.forEach((calendarFeed, i, calendarFeeds) => {
               // Get all events except those that are updated
@@ -300,7 +308,10 @@ export default {
               ];
             });
             return {
-              calendarFeeds: previousCalendarFeeds,
+              calendar: {
+                ...previousResult.calendar,
+                calendarFeeds: previousCalendarFeeds,
+              },
             };
           },
         });
diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
index 04421cb09ee6c9e6a23bd59a5ad4eb5e7c37d938..5748b1d5755b961ca901f075b4aac152f3874634 100644
--- a/aleksis/core/frontend/components/calendar/CalendarSelect.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
@@ -1,13 +1,5 @@
 <template>
   <v-list-item-group multiple v-model="model">
-    <v-subheader>
-      <v-checkbox
-        :label="$t('actions.select_all')"
-        :indeterminate="someSelected"
-        :input-value="allSelected"
-        @change="toggleAll"
-      />
-    </v-subheader>
     <v-list-item
       v-for="calendarFeed in calendarFeeds"
       :key="calendarFeed.name"
@@ -15,13 +7,12 @@
     >
       <template #default="{ active }">
         <v-list-item-action>
-          <v-checkbox :input-value="active"></v-checkbox>
+          <v-checkbox
+            :input-value="active"
+            :color="calendarFeed.color"
+          ></v-checkbox>
         </v-list-item-action>
 
-        <v-list-item-icon>
-          <v-icon class="mr-2" :color="calendarFeed.color"> mdi-circle </v-icon>
-        </v-list-item-icon>
-
         <v-list-item-content>
           <v-list-item-title>
             {{ calendarFeed.verboseName }}
@@ -29,10 +20,23 @@
         </v-list-item-content>
 
         <v-list-item-action>
-          <copy-to-clipboard-button
-            :text="calendarFeed.url"
-            :tooltip-help-text="$t('calendar.ics_to_clipboard')"
-          />
+          <v-menu bottom>
+            <template v-slot:activator="{ on, attrs }">
+              <v-btn fab x-small icon v-bind="attrs" v-on="on">
+                <v-icon>mdi-dots-vertical</v-icon>
+              </v-btn>
+            </template>
+            <v-list dense>
+              <v-list-item :href="calendarFeed.url">
+                <v-list-item-icon>
+                  <v-icon>mdi-calendar-export</v-icon>
+                </v-list-item-icon>
+                <v-list-item-title>
+                  {{ $t("calendar.download_ics") }}
+                </v-list-item-title>
+              </v-list-item>
+            </v-list>
+          </v-menu>
         </v-list-item-action>
       </template>
     </v-list-item>
@@ -40,8 +44,6 @@
 </template>
 
 <script>
-import CopyToClipboardButton from "../generic/CopyToClipboardButton.vue";
-
 export default {
   name: "CalendarSelect",
   props: {
@@ -54,9 +56,6 @@ export default {
       required: true,
     },
   },
-  components: {
-    CopyToClipboardButton,
-  },
   computed: {
     model: {
       get() {
diff --git a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1868b314a1af1106fe30b54571f629f2a577995f
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
@@ -0,0 +1,61 @@
+<script>
+export default {
+  name: "CalendarTypeSelect",
+  props: {
+    value: {
+      type: String,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      innerValue: this.value,
+      availableCalendarTypes: [
+        {
+          type: "month",
+          translationKey: "calendar.month",
+          icon: "calendar-month-outline",
+        },
+        {
+          type: "week",
+          translationKey: "calendar.week",
+          icon: "calendar-week-outline",
+        },
+        {
+          type: "day",
+          translationKey: "calendar.day",
+          icon: "calendar-today-outline",
+        },
+      ],
+    };
+  },
+  watch: {
+    value(val) {
+      this.innerValue = val;
+    },
+    innerValue(val) {
+      this.$emit("input", val);
+    },
+  },
+  methods: {
+    nameForMenu(item) {
+      return this.$t(item.translationKey);
+    },
+  },
+};
+</script>
+
+<template>
+  <v-btn-toggle dense v-model="innerValue" class="mx-2">
+    <v-btn
+      v-for="calendarType in availableCalendarTypes"
+      :value="calendarType.type"
+      :key="calendarType.type"
+    >
+      <v-icon v-if="$vuetify.breakpoint.smAndDown">{{
+        "mdi-" + calendarType.icon
+      }}</v-icon>
+      <span class="hidden-sm-and-down">{{ nameForMenu(calendarType) }}</span>
+    </v-btn>
+  </v-btn-toggle>
+</template>
diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
index 4c890629fb1662ccfa21102194d6dbbf4f2c40df..2b6f8fa1e5833260196f97a4e939187f87c80c3f 100644
--- a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
+++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue
@@ -1,5 +1,16 @@
 <template>
-  <base-calendar-feed-details v-bind="$props" />
+  <div>
+    <base-calendar-feed-details v-bind="$props" />
+    <v-divider inset v-if="selectedEvent.location" />
+    <v-list-item v-if="selectedEvent.location">
+      <v-list-item-icon>
+        <v-icon color="primary">mdi-map-marker-outline</v-icon>
+      </v-list-item-icon>
+      <v-list-item-content>
+        {{ selectedEvent.location }}
+      </v-list-item-content>
+    </v-list-item>
+  </div>
 </template>
 
 <script>
diff --git a/aleksis/core/frontend/components/calendar/calendarOverview.graphql b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
index 777d2f5253ab24e7c07378eaf57e033f5bee874d..f277ef4bbffcf9cf68532a5e1ba9ce3ecd2c813b 100644
--- a/aleksis/core/frontend/components/calendar/calendarOverview.graphql
+++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql
@@ -1,21 +1,26 @@
 query ($start: Date, $end: Date) {
-  calendarFeeds {
-    name
-    verboseName
-    description
-    url
-    color
-    feed {
-      events(start: $start, end: $end) {
-        name
-        start
-        end
-        color
-        description
-        uid
-        allDay
-        status
-        meta
+  calendar {
+    allFeedsUrl
+    calendarFeeds {
+      name
+      verboseName
+      description
+      url
+      color
+      activated
+      feed {
+        events(start: $start, end: $end) {
+          name
+          start
+          end
+          color
+          description
+          location
+          uid
+          allDay
+          status
+          meta
+        }
       }
     }
   }
diff --git a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..633f0791c3e00f0c82886779366704086871c484
--- /dev/null
+++ b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql
@@ -0,0 +1,5 @@
+mutation ($calendars: [String]!) {
+  setCalendarStatus(calendars: $calendars) {
+    ok
+  }
+}
diff --git a/aleksis/core/frontend/components/holiday/HolidayInlineList.vue b/aleksis/core/frontend/components/holiday/HolidayInlineList.vue
index 07126079b04b793b54a223ec25c49100f01b4dd5..a7330f94db9b752c0fe80d5bb4fd6b1652a87b6e 100644
--- a/aleksis/core/frontend/components/holiday/HolidayInlineList.vue
+++ b/aleksis/core/frontend/components/holiday/HolidayInlineList.vue
@@ -5,24 +5,24 @@ import DateField from "../generic/forms/DateField.vue";
 
 <template>
   <inline-c-r-u-d-list
-      :headers="headers"
-      :i18n-key="i18nKey"
-      create-item-i18n-key="holidays.create_holiday"
-      :gql-query="gqlQuery"
-      :gql-create-mutation="gqlCreateMutation"
-      :gql-patch-mutation="gqlPatchMutation"
-      :gql-delete-mutation="gqlDeleteMutation"
-      :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
-      :default-item="defaultItem"
-      ref="crudList"
+    :headers="headers"
+    :i18n-key="i18nKey"
+    create-item-i18n-key="holidays.create_holiday"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+    :default-item="defaultItem"
+    ref="crudList"
   >
     <template #holidayName.field="{ attrs, on, isCreate }">
       <div aria-required="true">
         <v-text-field
-            v-bind="attrs"
-            v-on="on"
-            required
-            :rules="required"
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="required"
         ></v-text-field>
       </div>
     </template>
@@ -33,11 +33,11 @@ import DateField from "../generic/forms/DateField.vue";
     <template #dateStart.field="{ attrs, on, item, isCreate }">
       <div aria-required="true">
         <date-field
-            v-bind="attrs"
-            v-on="on"
-            :rules="required"
-            :max="item ? item.dateEnd : undefined"
-            @input="updateEndDate($event, item, isCreate)"
+          v-bind="attrs"
+          v-on="on"
+          :rules="required"
+          :max="item ? item.dateEnd : undefined"
+          @input="updateEndDate($event, item, isCreate)"
         ></date-field>
       </div>
     </template>
@@ -48,11 +48,11 @@ import DateField from "../generic/forms/DateField.vue";
     <template #dateEnd.field="{ attrs, on, item }">
       <div aria-required="true">
         <date-field
-            v-bind="attrs"
-            v-on="on"
-            required
-            :rules="required"
-            :min="item ? item.dateStart : undefined"
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="required"
+          :min="item ? item.dateStart : undefined"
         ></date-field>
       </div>
     </template>
@@ -60,7 +60,13 @@ import DateField from "../generic/forms/DateField.vue";
 </template>
 
 <script>
-import {holidays, createHoliday, deleteHoliday, deleteHolidays, updateHolidays} from "./holiday.graphql";
+import {
+  holidays,
+  createHoliday,
+  deleteHoliday,
+  deleteHolidays,
+  updateHolidays,
+} from "./holiday.graphql";
 
 export default {
   name: "HolidayInlineList",
@@ -96,21 +102,23 @@ export default {
   },
   methods: {
     updateEndDate(newStartDate, item, isCreate) {
-      console.log("method called", item)
+      console.log("method called", item);
       let start = new Date(newStartDate);
-      console.log(start)
+      console.log(start);
       if (!item.endDate) {
         if (isCreate) {
           this.$refs.crudList.createModel.dateEnd = newStartDate;
           console.log("Changed of createmodel");
         } else {
-          this.$refs.crudList.editableItems.find(holiday => holiday.id === item.id)[0].dateEnd = newStartDate;
+          this.$refs.crudList.editableItems.find(
+            (holiday) => holiday.id === item.id
+          )[0].dateEnd = newStartDate;
           console.log("Changed of editableitems");
         }
       } else {
         console.log(item, newStartDate);
       }
-    }
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/holiday/holiday.graphql b/aleksis/core/frontend/components/holiday/holiday.graphql
index 0b4742c3f304671c199c2ad9aa5f5cd95445b666..c76ff12f32b8f178188c94c2633e609e31854148 100644
--- a/aleksis/core/frontend/components/holiday/holiday.graphql
+++ b/aleksis/core/frontend/components/holiday/holiday.graphql
@@ -1,48 +1,48 @@
 query holidays($orderBy: [String], $filters: JSONString) {
-    items: holidays(orderBy: $orderBy, filters: $filters) {
-        id
-        holidayName
-        dateStart
-        dateEnd
-        canEdit
-        canDelete
-    }
+  items: holidays(orderBy: $orderBy, filters: $filters) {
+    id
+    holidayName
+    dateStart
+    dateEnd
+    canEdit
+    canDelete
+  }
 }
 
 mutation createHoliday($input: CreateHolidayInput!) {
-    createHoliday(input: $input) {
-        holiday {
-            id
-            holidayName
-            dateStart
-            dateEnd
-            canEdit
-            canDelete
-        }
+  createHoliday(input: $input) {
+    holiday {
+      id
+      holidayName
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
     }
+  }
 }
 
 mutation deleteHoliday($id: ID!) {
-    deleteHoliday(id: $id) {
-        ok
-    }
+  deleteHoliday(id: $id) {
+    ok
+  }
 }
 
 mutation deleteHolidays($ids: [ID]!) {
-    deleteHolidays(ids: $ids) {
-        deletionCount
-    }
+  deleteHolidays(ids: $ids) {
+    deletionCount
+  }
 }
 
 mutation updateHolidays($input: [BatchPatchHolidayInput]!) {
-    batchMutation: updateHolidays(input: $input) {
-        items: holidays {
-            id
-            holidayName
-            dateStart
-            dateEnd
-            canEdit
-            canDelete
-        }
+  batchMutation: updateHolidays(input: $input) {
+    items: holidays {
+      id
+      holidayName
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
     }
+  }
 }
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 439c9aeb2db072dbdbf8c4d213721efc98cfda78..a477736639405a1005f8799ce09b6c88f45a95de 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -256,19 +256,22 @@
     "new_version_available": "A new version of the app is available",
     "update": "Update"
   },
-  "graphql": {
-    "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
-    "snackbar_success_message": "The operation has been finished successfully."
-  },
   "calendar": {
-    "menu_title_overview": "Calendar",
+    "menu_title": "Calendar",
     "month": "Month",
     "week": "Week",
     "day": "Day",
     "today": "Today",
     "select": "Select calendars",
     "ics_to_clipboard": "Copy link to calendar ICS to clipboard",
-    "cancelled": "Cancelled"
+    "cancelled": "Cancelled",
+    "download_ics": "Download ICS",
+    "my_calendars": "My Calendars",
+    "download_all": "Download all"
+  },
+  "graphql": {
+    "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
+    "snackbar_success_message": "The operation has been finished successfully."
   },
   "status": {
     "changes": "You have unsaved changes.",
diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js
index 4d3e9beda841af3b84eadbcb8fab910525ca518c..0f729ba18355f7742d9080ea0188e8f3ba73e193 100644
--- a/aleksis/core/frontend/mixins/calendarFeedDetails.js
+++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js
@@ -32,6 +32,10 @@ const calendarFeedDetailsMixin = {
       type: String,
       default: null,
     },
+    calendarType: {
+      required: true,
+      type: String,
+    },
   },
   computed: {
     model: {
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 1c3d19d55061869cfa1d740af9160b2c643b0844..e8bc2e6a6232172e27c68daeb32d2410b8e0a04f 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -1061,14 +1061,15 @@ const routes = [
     name: "invitations.accept_invite",
   },
   {
-    path: "/calendar/overview",
+    path: "/calendar/",
     component: () => import("./components/calendar/CalendarOverview.vue"),
     name: "core.calendar_overview",
     meta: {
       inMenu: true,
       icon: "mdi-calendar",
-      titleKey: "calendar.menu_title_overview",
-      validators: [hasPersonValidator],
+      titleKey: "calendar.menu_title",
+      toolbarTitle: "calendar.menu_title",
+      permission: "core.view_calendar_feed_rule",
     },
   },
 ];
diff --git a/aleksis/core/migrations/0049_calendarevent.py b/aleksis/core/migrations/0049_calendarevent.py
deleted file mode 100644
index d2f54fc4a353c982e5e5da6b0a879cf484d4dc71..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0049_calendarevent.py
+++ /dev/null
@@ -1,76 +0,0 @@
-# Generated by Django 4.1.5 on 2023-01-29 13:46
-
-import aleksis.core.managers
-import aleksis.core.mixins
-from django.db import migrations, models
-import django.db.models.deletion
-import recurrence.fields
-
-
-class Migration(migrations.Migration):
-
-    dependencies = [
-        ("sites", "0002_alter_domain_unique"),
-        ("contenttypes", "0002_remove_content_type_name"),
-        ("core", "0048_delete_personalicalurl"),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="CalendarEvent",
-            fields=[
-                (
-                    "id",
-                    models.BigAutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
-                    ),
-                ),
-                ("extended_data", models.JSONField(default=dict, editable=False)),
-                ("start", models.DateTimeField(verbose_name="Start date and time")),
-                ("end", models.DateTimeField(verbose_name="End date and time")),
-                (
-                    "recurrences",
-                    recurrence.fields.RecurrenceField(
-                        blank=True, null=True, verbose_name="Recurrences"
-                    ),
-                ),
-                (
-                    "ammends",
-                    models.ForeignKey(
-                        blank=True,
-                        null=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="core.calendarevent",
-                        verbose_name="Ammended base event",
-                    ),
-                ),
-                (
-                    "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",
-                    ),
-                ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.site",
-                    ),
-                ),
-            ],
-            options={
-                "verbose_name": "Calendar Event",
-                "verbose_name_plural": "Calendar Events",
-            },
-            bases=(aleksis.core.mixins.CalendarEventMixin, models.Model),
-            managers=[
-                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
-            ],
-        ),
-    ]
diff --git a/aleksis/core/migrations/0050_fix_amends.py b/aleksis/core/migrations/0050_fix_amends.py
deleted file mode 100644
index 09ea72fb0f44577aec4284343420dc7253e478cb..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0050_fix_amends.py
+++ /dev/null
@@ -1,19 +0,0 @@
-# Generated by Django 4.1.7 on 2023-03-26 10:26
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("sites", "0002_alter_domain_unique"),
-        ("core", "0049_calendarevent"),
-    ]
-
-    operations = [
-        migrations.RenameField(
-            model_name="calendarevent",
-            old_name="ammends",
-            new_name="amends",
-        ),
-    ]
diff --git a/aleksis/core/migrations/0051_calendarevent_and_holiday.py b/aleksis/core/migrations/0051_calendarevent_and_holiday.py
new file mode 100644
index 0000000000000000000000000000000000000000..28e5294027d766f64d6cb644696e84f7a9c2a6ac
--- /dev/null
+++ b/aleksis/core/migrations/0051_calendarevent_and_holiday.py
@@ -0,0 +1,164 @@
+# Generated by Django 4.1.10 on 2023-07-11 19:01
+
+import aleksis.core.managers
+import aleksis.core.mixins
+from django.db import migrations, models
+import django.db.models.deletion
+import recurrence.fields
+import timezone_field.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0050_managed_by_app_label"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="CalendarEvent",
+            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)),
+                (
+                    "datetime_start",
+                    models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"),
+                ),
+                (
+                    "datetime_end",
+                    models.DateTimeField(blank=True, null=True, verbose_name="End date and time"),
+                ),
+                (
+                    "timezone",
+                    timezone_field.fields.TimeZoneField(
+                        blank=True, null=True, verbose_name="Timezone"
+                    ),
+                ),
+                ("date_start", models.DateField(blank=True, null=True, verbose_name="Start date")),
+                ("date_end", models.DateField(blank=True, null=True, verbose_name="End date")),
+                (
+                    "recurrences",
+                    recurrence.fields.RecurrenceField(
+                        blank=True, null=True, verbose_name="Recurrences"
+                    ),
+                ),
+                (
+                    "amends",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="amended_by",
+                        to="core.calendarevent",
+                        verbose_name="Amended base event",
+                    ),
+                ),
+                (
+                    "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",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to="sites.site",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Calendar Event",
+                "verbose_name_plural": "Calendar Events",
+                "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"],
+            },
+            bases=(aleksis.core.mixins.CalendarEventMixin, models.Model),
+            managers=[
+                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name="Holiday",
+            fields=[
+                (
+                    "calendarevent_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="core.calendarevent",
+                    ),
+                ),
+                ("holiday_name", models.CharField(max_length=255, verbose_name="Name")),
+            ],
+            options={
+                "verbose_name": "Holiday",
+                "verbose_name_plural": "Holidays",
+            },
+            bases=("core.calendarevent",),
+            managers=[
+                ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("date_start__isnull", True), ("datetime_start__isnull", True), _negated=True
+                ),
+                name="datetime_start_or_date_start",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("date_end__isnull", True), ("datetime_end__isnull", True), _negated=True
+                ),
+                name="datetime_end_or_date_end",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("datetime_start__isnull", False), ("timezone__isnull", True), _negated=True
+                ),
+                name="timezone_if_datetime_start",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="calendarevent",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("datetime_end__isnull", False), ("timezone__isnull", True), _negated=True
+                ),
+                name="timezone_if_datetime_end",
+            ),
+        ),
+    ]
diff --git a/aleksis/core/migrations/0051_calendarevent_dates.py b/aleksis/core/migrations/0051_calendarevent_dates.py
deleted file mode 100644
index e4b0bc3e763a0b00447f733d0c26f8c65373119f..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0051_calendarevent_dates.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# Generated by Django 4.1.8 on 2023-04-08 15:25
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("sites", "0002_alter_domain_unique"),
-        ("core", "0050_fix_amends"),
-    ]
-
-    operations = [
-        migrations.AlterModelOptions(
-            name="calendarevent",
-            options={
-                "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"],
-                "verbose_name": "Calendar Event",
-                "verbose_name_plural": "Calendar Events",
-            },
-        ),
-        migrations.RenameField(
-            model_name="calendarevent",
-            old_name="end",
-            new_name="datetime_end",
-        ),
-        migrations.RenameField(
-            model_name="calendarevent",
-            old_name="start",
-            new_name="datetime_start",
-        ),
-        migrations.AddField(
-            model_name="calendarevent",
-            name="date_end",
-            field=models.DateField(blank=True, null=True, verbose_name="End date"),
-        ),
-        migrations.AddField(
-            model_name="calendarevent",
-            name="date_start",
-            field=models.DateField(blank=True, null=True, verbose_name="Start date"),
-        ),
-        migrations.AlterField(
-            model_name="calendarevent",
-            name="amends",
-            field=models.ForeignKey(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="core.calendarevent",
-                verbose_name="Amended base event",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="calendarevent",
-            name="datetime_end",
-            field=models.DateTimeField(blank=True, null=True, verbose_name="End date and time"),
-        ),
-        migrations.AlterField(
-            model_name="calendarevent",
-            name="datetime_start",
-            field=models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"),
-        ),
-        migrations.AlterField(
-            model_name="calendarevent",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="calendarevent",
-            constraint=models.CheckConstraint(
-                check=models.Q(
-                    ("date_start__isnull", True), ("datetime_start__isnull", True), _negated=True
-                ),
-                name="datetime_start_or_date_start",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="calendarevent",
-            constraint=models.CheckConstraint(
-                check=models.Q(
-                    ("date_end__isnull", True), ("datetime_end__isnull", True), _negated=True
-                ),
-                name="datetime_end_or_date_end",
-            ),
-        ),
-    ]
diff --git a/aleksis/core/migrations/0052_holiday.py b/aleksis/core/migrations/0052_holiday.py
deleted file mode 100644
index ed2e75ba011261884c26e9e695f85595d0b63e91..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0052_holiday.py
+++ /dev/null
@@ -1,36 +0,0 @@
-# Generated by Django 4.1.8 on 2023-04-09 14:09
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("sites", "0002_alter_domain_unique"),
-        ("core", "0051_calendarevent_dates"),
-    ]
-
-    operations = [
-        migrations.CreateModel(
-            name="Holiday",
-            fields=[
-                (
-                    "calendarevent_ptr",
-                    models.OneToOneField(
-                        auto_created=True,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        parent_link=True,
-                        primary_key=True,
-                        serialize=False,
-                        to="core.calendarevent",
-                    ),
-                ),
-                ("holiday_name", models.CharField(max_length=255, verbose_name="Name")),
-            ],
-            options={
-                "verbose_name": "Holiday",
-                "verbose_name_plural": "Holidays",
-            },
-            bases=("core.calendarevent",),
-        ),
-    ]
diff --git a/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py b/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py
deleted file mode 100644
index 452e18e2a9bed5be3c42f19eb313b9a54df5a27d..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py
+++ /dev/null
@@ -1,223 +0,0 @@
-# Generated by Django 4.1.8 on 2023-05-01 17:55
-
-from django.db import migrations, models
-import django.db.models.deletion
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("sites", "0002_alter_domain_unique"),
-        ("core", "0052_holiday"),
-    ]
-
-    operations = [
-        migrations.AlterModelManagers(
-            name="calendarevent",
-            managers=[],
-        ),
-        migrations.AlterField(
-            model_name="activity",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="additionalfield",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="announcement",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="announcementrecipient",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="custommenu",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="custommenuitem",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="dashboardwidgetorder",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="datacheckresult",
-            name="data_check",
-            field=models.CharField(
-                choices=[
-                    (
-                        "broken_dashboard_widgets",
-                        "Ensure that there are no broken DashboardWidgets.",
-                    ),
-                    (
-                        "field_validation_custommenuitem_icon",
-                        "Validate field icon of model core.CustomMenuItem.",
-                    ),
-                ],
-                max_length=255,
-                verbose_name="Related data check task",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="datacheckresult",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="group",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="grouptype",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="notification",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="pdffile",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="person",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="persongroupthrough",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="room",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="schoolterm",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-        migrations.AlterField(
-            model_name="taskuserassignment",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="+",
-                to="sites.site",
-            ),
-        ),
-    ]
diff --git a/aleksis/core/migrations/0054_calendarevent_timezone.py b/aleksis/core/migrations/0054_calendarevent_timezone.py
deleted file mode 100644
index f2a98b3d712c9e3dcc2410ff398902370d8e8b30..0000000000000000000000000000000000000000
--- a/aleksis/core/migrations/0054_calendarevent_timezone.py
+++ /dev/null
@@ -1,51 +0,0 @@
-# Generated by Django 4.1.9 on 2023-05-29 11:00
-
-from django.db import migrations, models
-import django.db.models.deletion
-import timezone_field.fields
-
-
-class Migration(migrations.Migration):
-    dependencies = [
-        ("core", "0053_alter_calendarevent_managers_alter_activity_site_and_more"),
-    ]
-
-    operations = [
-        migrations.AddField(
-            model_name="calendarevent",
-            name="timezone",
-            field=timezone_field.fields.TimeZoneField(
-                blank=True, null=True, verbose_name="Timezone"
-            ),
-        ),
-        migrations.AlterField(
-            model_name="calendarevent",
-            name="amends",
-            field=models.ForeignKey(
-                blank=True,
-                null=True,
-                on_delete=django.db.models.deletion.CASCADE,
-                related_name="amended_by",
-                to="core.calendarevent",
-                verbose_name="Amended base event",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="calendarevent",
-            constraint=models.CheckConstraint(
-                check=models.Q(
-                    ("datetime_start__isnull", False), ("timezone__isnull", True), _negated=True
-                ),
-                name="timezone_if_datetime_start",
-            ),
-        ),
-        migrations.AddConstraint(
-            model_name="calendarevent",
-            constraint=models.CheckConstraint(
-                check=models.Q(
-                    ("datetime_end__isnull", False), ("timezone__isnull", True), _negated=True
-                ),
-                name="timezone_if_datetime_end",
-            ),
-        ),
-    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 0da763a8e9c6ce049245e19e3dd8ac065f835abc..c6c19af0a5f58a105377bb2a36502b562056739d 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -14,7 +14,7 @@ from django.db import models
 from django.db.models import JSONField, QuerySet
 from django.db.models.fields import CharField, TextField
 from django.forms.forms import BaseForm
-from django.forms.models import ModelForm, ModelFormMetaclass
+from django.forms.models import ModelForm, ModelFormMetaclass, fields_for_model
 from django.http import HttpRequest, HttpResponse
 from django.utils.functional import classproperty, lazy
 from django.utils.translation import gettext as _
@@ -608,6 +608,11 @@ class RegistryObject:
         return cls.registered_objects_dict.get(name)
 
 
+class ObjectAuthenticator(RegistryObject):
+    def authenticate(self, request, obj):
+        raise NotImplementedError()
+
+
 class CalendarEventMixin(RegistryObject):
     """Mixin for calendar feeds.
 
@@ -741,7 +746,20 @@ class CalendarEventMixin(RegistryObject):
     def value_color(cls, reference_object, request) -> str:
         return cls.get_color(request)
 
+    @classproperty
+    def valid_feed(cls):
+        """Return if the feed is valid."""
+        return cls.name != cls.__name__
+
     @classproperty
     def valid_feeds(cls):
         """Return a list of valid feeds."""
-        return [feed for feed in cls.registered_objects_list if feed.name != feed.__name__]
+        return [feed for feed in cls.registered_objects_list if feed.valid_feed]
+
+    @classproperty
+    def valid_feed_names(cls):
+        """Return a list of valid feed names."""
+        return [feed.name for feed in cls.valid_feeds]
+
+    def get_object_by_name(cls, name):
+        return cls.registered_objects_dict.get(name)
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 3f6f6d915a4570855317de09939c980b25069949..4151d51b39d4cafac8ba91250a5ed64b8d4af50e 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -1562,11 +1562,15 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel):
     @classmethod
     def value_end_datetime(
         cls, reference_object: "CalendarEvent", request
-    ) -> Union[datetime, date]:
+    ) -> Union[datetime, date, None]:
         """Return the end datetime of the calendar event."""
         if reference_object.datetime_end:
             return reference_object.datetime_end.astimezone(reference_object.timezone)
-        return reference_object.date_end + timedelta(days=1)
+        if reference_object.date_end == reference_object.date_start:
+            # Rule for all day events: If the event is only one day long,
+            # the end date has to be empty
+            return None
+        return reference_object.date_end
 
     @classmethod
     def value_rrule(cls, reference_object: "CalendarEvent", request) -> Optional[vRecur]:
diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py
index 4c9758e16e3722c8775e50a11004c533366b3f05..a3be1ac89770a89c2d3ea82b64aee7adc3913c9f 100644
--- a/aleksis/core/preferences.py
+++ b/aleksis/core/preferences.py
@@ -16,7 +16,7 @@ from dynamic_preferences.types import (
 )
 from oauth2_provider.models import AbstractApplication
 
-from .mixins import PublicFilePreferenceMixin
+from .mixins import CalendarEventMixin, PublicFilePreferenceMixin
 from .models import Group, Person
 from .registries import person_preferences_registry, site_preferences_registry
 from .util.notifications import get_notification_choices_lazy
@@ -502,3 +502,18 @@ class HolidayFeedColor(StringPreference):
     verbose_name = _("Holiday calendar feed color")
     widget = ColorWidget
     required = True
+
+
+@person_preferences_registry.register
+class ActivatedCalendars(MultipleChoicePreference):
+    """Calendars that are activated for a person."""
+
+    section = calendar
+    name = "activated_calendars"
+    default = []
+    widget = SelectMultiple
+    verbose_name = _("Activated calendars")
+    required = False
+
+    field_attribute = {"initial": []}
+    choices = [(feed.name, feed.verbose_name) for feed in CalendarEventMixin.valid_feeds]
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 359b5164a52a21ca1f2cb9e39d35af2c5b9ae646..3279e7b12e31318a190c0d2fa738be8fc2dc1eaa 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -9,7 +9,6 @@ from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.utils.loading import UnifiedIndex
 
-from ..mixins import CalendarEventMixin
 from ..models import (
     CustomMenu,
     DynamicRoute,
@@ -24,7 +23,7 @@ from ..models import (
 from ..util.apps import AppConfig
 from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person
 from .base import FilterOrderList
-from .calendar import CalendarType
+from .calendar import CalendarBaseType, SetCalendarStatusMutation
 from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
 from .dynamic_routes import DynamicRouteType
@@ -98,16 +97,13 @@ class Query(graphene.ObjectType):
 
     oauth_access_tokens = graphene.List(OAuthAccessTokenType)
 
-    calendar_feeds = graphene.List(CalendarType)
-
-    calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String))
-
     rooms = FilterOrderList(RoomType)
     room_by_id = graphene.Field(RoomType, id=graphene.ID())
 
     school_terms = FilterOrderList(SchoolTermType)
 
     holidays = FilterOrderList(HolidayType)
+    calendar = graphene.Field(CalendarBaseType)
 
     def resolve_ping(root, info, payload) -> str:
         return payload
@@ -146,7 +142,7 @@ class Query(graphene.ObjectType):
         return get_objects_for_user(info.context.user, "core.view_group", Group)
 
     @staticmethod
-    def resolve_group_by_id(root, info, id):
+    def resolve_group_by_id(root, info, id): # noqa
         group = Group.objects.filter(id=id)
 
         if len(group) != 1:
@@ -228,12 +224,6 @@ class Query(graphene.ObjectType):
     def resolve_oauth_access_tokens(root, info, **kwargs):
         return OAuthAccessToken.objects.filter(user=info.context.user)
 
-    def resolve_calendar_feeds(root, info, **kwargs):
-        return CalendarEventMixin.valid_feeds
-
-    def resolve_calendar_feeds_by_names(root, info, names, **kwargs):
-        return [CalendarEventMixin.get_object_by_name(name) for name in names]
-
     @staticmethod
     def resolve_room_by_id(root, info, **kwargs):
         pk = kwargs.get("id")
@@ -244,6 +234,10 @@ class Query(graphene.ObjectType):
 
         return room_object
 
+    @staticmethod
+    def resolve_calendar(root, info, **kwargs):
+        return True
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
@@ -269,6 +263,8 @@ class Mutation(graphene.ObjectType):
     delete_holidays = HolidayBatchDeleteMutation.Field()
     update_holidays = HolidayBatchPatchMutation.Field()
 
+    set_calendar_status = SetCalendarStatusMutation.Field()
+
 
 def build_global_schema():
     """Build global GraphQL schema from all apps."""
diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py
index 9aa2ad53227b1a3dca1f9b6ab0f0c4a629d21fe8..110e2cf826f26314b278bf293724712473a61ba8 100644
--- a/aleksis/core/schema/calendar.py
+++ b/aleksis/core/schema/calendar.py
@@ -1,14 +1,19 @@
 from datetime import datetime
 
+from django.core.exceptions import PermissionDenied
 from django.urls import reverse
 
 import graphene
 from graphene import ObjectType
 
+from aleksis.core.mixins import CalendarEventMixin
+from aleksis.core.util.core_helpers import has_person
+
 
 class CalendarEventType(ObjectType):
     name = graphene.String()
     description = graphene.String()
+    location = graphene.String(required=False)
     start = graphene.String()
     end = graphene.String()
     color = graphene.String()
@@ -23,6 +28,9 @@ class CalendarEventType(ObjectType):
     def resolve_description(root, info, **kwargs):
         return root["DESCRIPTION"]
 
+    def resolve_location(root, info, **kwargs):
+        return root.get("LOCATION", "")
+
     def resolve_start(root, info, **kwargs):
         return root["DTSTART"].dt
 
@@ -42,7 +50,7 @@ class CalendarEventType(ObjectType):
         return root.get("STATUS", "")
 
     def resolve_meta(root, info, **kwargs):
-        return root.get("X-META", {})
+        return root.get("X-META", "{}")
 
 
 class CalendarFeedType(ObjectType):
@@ -65,6 +73,8 @@ class CalendarType(ObjectType):
 
     url = graphene.String()
 
+    activated = graphene.Boolean()
+
     def resolve_verbose_name(root, info, **kwargs):
         return root.get_verbose_name(info.context)
 
@@ -79,3 +89,39 @@ class CalendarType(ObjectType):
 
     def resolve_color(root, info, **kwargs):
         return root.get_color(info.context)
+
+    def resolve_activated(root, info, **kwargs):
+        return root.name in info.context.user.person.preferences["calendar__activated_calendars"]
+
+
+class SetCalendarStatusMutation(graphene.Mutation):
+    """Mutation to change the status of a calendar."""
+
+    class Arguments:
+        calendars = graphene.List(graphene.String)
+
+    ok = graphene.Boolean()
+
+    def mutate(root, info, calendars, **kwargs):
+        if not has_person(info.context):
+            raise PermissionDenied
+        calendar_feeds = [cal for cal in calendars if cal in CalendarEventMixin.valid_feed_names]
+        info.context.user.person.preferences["calendar__activated_calendars"] = calendar_feeds
+        return SetCalendarStatusMutation(ok=True)
+
+
+class CalendarBaseType(ObjectType):
+    calendar_feeds = graphene.List(CalendarType)
+
+    calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String))
+
+    all_feeds_url = graphene.String()
+
+    def resolve_calendar_feeds(root, info, **kwargs):
+        return CalendarEventMixin.valid_feeds
+
+    def resolve_calendar_feeds_by_names(root, info, names, **kwargs):
+        return [CalendarEventMixin.get_object_by_name(name) for name in names]
+
+    def resolve_all_feeds_url(root, info, **kwargs):
+        return info.context.build_absolute_uri(reverse("all_calendar_feeds"))
diff --git a/aleksis/core/schema/holiday.py b/aleksis/core/schema/holiday.py
index 80d82045441ea16c9184a61ac8eed636e1c5daa6..b742b373e8f368ae2f6760f1361340b36a07fa7a 100644
--- a/aleksis/core/schema/holiday.py
+++ b/aleksis/core/schema/holiday.py
@@ -1,17 +1,21 @@
 from graphene_django import DjangoObjectType
-from graphene_django_cud.mutations import DjangoBatchDeleteMutation, DjangoBatchPatchMutation, DjangoCreateMutation
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
 
 from ..models import Holiday
 from .base import (
     DeleteMutation,
     DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
     PermissionBatchPatchMixin,
     PermissionsTypeMixin,
-    PermissionBatchDeleteMixin,
 )
 
 
-class HolidayType(PermissionsTypeMixin, DjangoFilterMixin,  DjangoObjectType):
+class HolidayType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
     class Meta:
         model = Holiday
         fields = ("id", "holiday_name", "date_start", "date_end")
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index b11680e95d9c06ba0d3d3eb041fcb9a3b28ea995..775dd392c5f79e3c43aeae19c3eea1247397457f 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -939,7 +939,7 @@ LOGGING["root"] = {
 }
 # Configure global log Format
 LOGGING["formatters"]["verbose"] = {
-    "format": "{asctime} {levelname} {name}[{process}]: {msg}",
+    "format": "{asctime} {levelname} {name}[{process}]: {message}",
     "style": "{",
 }
 # Add null handler for selective silencing
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 245fb296acfd8b56dd60c1695f11d60ddcac903b..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -42,6 +42,21 @@ urlpatterns = [
     ),
     path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")),
     path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"),
+    path(
+        "o/<str:app_label>/<str:model>/<int:pk>/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_with_pk",
+    ),
+    path(
+        "o/<str:app_label>/<str:model>/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_with_model",
+    ),
+    path(
+        "o/",
+        views.ObjectRepresentationView.as_view(),
+        name="object_representation_anonymous",
+    ),
     path("", include("django_prometheus.urls")),
     path(
         "django/",
@@ -387,6 +402,7 @@ urlpatterns = [
                     name="assign_permission",
                 ),
                 path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"),
+                path("feeds.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"),
             ]
         ),
     ),
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 26bcd0b83521c76581be52ca1272b672c0f01696..9329dda6192b554efa5e546a0160b5225e09da17 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -498,7 +498,6 @@ def get_ip(*args, **kwargs):
 feedgenerator.FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),)
 feedgenerator.ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + (
     ("color", "color"),
-    ("recurrence_id", "recurrence-id"),
     ("meta", "x-meta"),
 )
 
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 9bd7430e50fbbced00dae44cf9ded2a87ab96fca..f9e21e5b57472664e96bded0ebb471a11aff014b 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -101,6 +101,7 @@ from .mixins import (
     AdvancedDeleteView,
     AdvancedEditView,
     CalendarEventMixin,
+    ObjectAuthenticator,
     SuccessNextMixin,
 )
 from .models import (
@@ -1556,6 +1557,78 @@ class LoggingGraphQLView(GraphQLView):
         return result
 
 
+class ObjectRepresentationView(View):
+    """View with unique URL to get a JSON representation of an object."""
+
+    def get_model(self, request: HttpRequest, app_label: str, model: str):
+        """Get the model by app label and model name."""
+        try:
+            return apps.get_model(app_label, model)
+        except LookupError:
+            raise Http404()
+
+    def get_object(self, request: HttpRequest, app_label: str, model: str, pk: int):
+        """Get the object by app label, model name and primary key."""
+        if getattr(self, "model", None) is None:
+            self.model = self.get_model(request, app_label, model)
+
+        try:
+            return self.model.objects.get(pk=pk)
+        except self.model.DoesNotExist:
+            raise Http404()
+
+    def get(
+        self,
+        request: HttpRequest,
+        app_label: Optional[str] = None,
+        model: Optional[str] = None,
+        pk: Optional[int] = None,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
+        if app_label and model:
+            self.model = self.get_model(request, app_label, model)
+        else:
+            self.model = None
+
+        if app_label and model and pk:
+            self.object = self.get_object(request, app_label, model, pk)
+        else:
+            self.object = None
+
+        authenticators = request.GET.get("authenticators", "").split(",")
+        if authenticators == [""]:
+            authenticators = list(ObjectAuthenticator.registered_objects_dict.keys())
+        self.authenticate(request, authenticators)
+
+        if hasattr(self.object, "get_json"):
+            res = self.object.get_json(request)
+        else:
+            res = {"id": self.object.id}
+        res["_meta"] = {
+            "model": self.object._meta.model_name,
+            "app": self.object._meta.app_label,
+        }
+
+        return JsonResponse(res)
+
+    def authenticate(self, request: HttpRequest, authenticators: list[str]) -> bool:
+        """Authenticate the request against the given authenticators."""
+        for authenticator in authenticators:
+            authenticator_class = ObjectAuthenticator.get_object_by_name(authenticator)
+            if not authenticator_class:
+                continue
+            obj = authenticator_class().authenticate(request, self.object)
+            if obj:
+                if self.object is None:
+                    self.object = obj
+                elif obj != self.object:
+                    raise BadRequest("Ambiguous objects identified")
+                return True
+
+        raise PermissionDenied()
+
+
 class ICalFeedView(PermissionRequiredMixin, View):
     """View to generate an iCal feed for a calendar."""
 
@@ -1569,3 +1642,16 @@ class ICalFeedView(PermissionRequiredMixin, View):
             feed.write(response, "utf-8")
             return response
         raise Http404
+
+
+class ICalAllFeedsView(PermissionRequiredMixin, View):
+    """View to generate an iCal feed for all calendars."""
+
+    permission_required = "core.view_calendar_feed_rule"
+
+    def get(self, request, *args, **kwargs):
+        response = HttpResponse(content_type="text/calendar")
+        for calendar in CalendarEventMixin.valid_feeds:
+            feed = calendar.create_feed(request)
+            feed.write(response, "utf-8")
+        return response
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index 0d341f37bba0b64b50d9281e7d3fe2f93540256c..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -259,7 +259,7 @@ export default defineConfig({
         directoryIndex: null,
         navigateFallbackAllowlist: [
           new RegExp(
-            "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+            "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$"
           ),
         ],
         additionalManifestEntries: [
@@ -274,7 +274,7 @@ export default defineConfig({
         runtimeCaching: [
           {
             urlPattern: new RegExp(
-              "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+              "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$"
             ),
             handler: "CacheFirst",
           },
diff --git a/pyproject.toml b/pyproject.toml
index 971a392f511629e84beda415bc30fe9bb8d5dee7..63b60ab21d91ba67617a2014a8cf611e5822966a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -125,11 +125,11 @@ django-cte = "^1.1.5"
 pycountry = "^22.0.0"
 django-iconify = "^0.3"
 customidenticon = "^0.1.5"
-graphene-django = ">=3.0.0, <=3.1.2"
+graphene-django = ">=3.0.0, <=3.1.3"
 selenium = "^4.4.3"
 django-vite = "^2.0.2"
-graphene-django-cud = "^0.10.0"
-django-ical = "^1.8.3"
+graphene-django-cud = "^0.11.0"
+django-ical = "^1.9.2"
 django-recurrence = "^1.11.1"
 recurring-ical-events = "^2.0.2"
 django-timezone-field = "^5.0"