diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index e9c9c0c9f1494a2a28cc3c839a98749d489e82cf..79c61aca13842af150e089297cf3049876d11b5d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,14 +9,52 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+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.
+
+Added
+~~~~~
+
+* Frontend for managing rooms.
+* [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
+
+Changed
+~~~~~~~
+
+* Management of school terms was migrated to new frontend.
+
+Fixed
+~~~~~
+
+* [Docker] The build could silently continue even if frontend bundling failed, resulting
+  in an incomplete AlekSIS frontend app.
+* GraphQL mutations did not return errors in case of exceptions.
+
+`3.1.2`_ - 2023-07-05
+---------------------
+
+Changed
+~~~~~~~
+
+* uWSGI is now installed together with AlekSIS-Core per default.
+
 Fixed
 ~~~~~
 
 * Notifications were not properly shown in the frontend.
+* [Dev] Log levels were not correctly propagated to all loggers
+* [Dev] Log format did not contain all essential information
 * When navigating from legacy to legacy page, the latter would reload once for no reason.
+* The oauth authorization page was not accessible when the service worker was active.
+* [Docker] Clear obsolete bundle parts when adding apps using ONBUILD
+* Extensible forms that used a subset of fields did not render properly
 
-`3.1.1` - 2023-07-01
---------------------
+`3.1.1`_ - 2023-07-01
+---------------------
 
 Fixed
 ~~~~~
@@ -171,13 +209,13 @@ Changed
 
 * Show languages in local language
 * Rewrite of frontend (base template) using Vuetify
-  * Frontend bundling migrated from Webpack to Vite (cf. installation docs)
-  * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the
-    background
+    * Frontend bundling migrated from Webpack to Vite (cf. installation docs)
+    * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the
+      background
 * OIDC scope "profile" now exposes the avatar instead of the official photo
 * Based on Django 4.0
-  * Use built-in Redis cache backend
-  * Introduce PBKDF2-SHA1 password hashing
+    * Use built-in Redis cache backend
+    * Introduce PBKDF2-SHA1 password hashing
 * Persistent database connections are now health-checked as to not fail
   requests
 * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check`
@@ -203,8 +241,8 @@ Removed
 
 * iCal feed URLs for birthdays (will be reintroduced later)
 * [Dev] Django debug toolbar
-  * It caused major performance issues and is not useful with the new
-    frontend anymore
+    * It caused major performance issues and is not useful with the new
+      frontend anymore
 
 `2.12.3`_ - 2023-03-07
 ----------------------
@@ -355,9 +393,7 @@ Fixed
 * The menu button used to be displayed twice on smaller screens.
 * The icons were loaded from external servers instead from local server.
 * Weekdays were not translated if system locales were missing
-
-  * Added locales-all to base image and note to docs
-
+    * Added locales-all to base image and note to docs
 * The icons in the account menu were still the old ones.
 * Due to a merge error, the once removed account menu in the sidenav appeared again.
 * Scheduled notifications were shown on dashboard before time.
@@ -561,11 +597,9 @@ Changed
 
 * Configuration files are now deep merged by default
 * Improvements for shell_plus module loading
-
-  * core.Group model now takes precedence over auth.Group
-  * Name collisions are resolved by prefixing with the app label
-  * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD
-
+    * core.Group model now takes precedence over auth.Group
+    * Name collisions are resolved by prefixing with the app label
+    * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD
 * [Docker] Base image now contains curl, grep, less, sed, and pspg
 * Views raising a 404 error can now customise the message that is displayed on the error page
 * OpenID Connect is enabled by default now, without RSA support
@@ -1188,3 +1222,4 @@ Fixed
 .. _3.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0
 .. _3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1
 .. _3.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.1
+.. _3.1.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.2
diff --git a/Dockerfile b/Dockerfile
index 2134ae81150a13dbab8282149a04df0fb8d20235..4a01dcc62c57fbcef4f56299f39f08d43675fb84 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -126,7 +126,7 @@ ONBUILD RUN set -e; \
                 eatmydata pip install $APPS; \
             fi; \
             eatmydata aleksis-admin vite build; \
-            eatmydata aleksis-admin collectstatic --no-input; \
+            eatmydata aleksis-admin collectstatic --no-input --clear; \
             rm -rf /usr/local/share/.cache; \
             eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
             eatmydata apt-get autoremove --purge -y; \
diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index c6c15e76465feb96363eb95f225549d0b65b3d92..e26390e7f7bee01ce8f63cdca699ac770c49be24 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -32,7 +32,6 @@ from .models import (
     OAuthApplication,
     Person,
     PersonInvitation,
-    SchoolTerm,
 )
 from .registries import (
     group_preferences_registry,
@@ -379,16 +378,6 @@ class EditGroupTypeForm(forms.ModelForm):
         fields = ["name", "description"]
 
 
-class SchoolTermForm(ExtensibleForm):
-    """Form for managing school years."""
-
-    layout = Layout("name", Row("date_start", "date_end"))
-
-    class Meta:
-        model = SchoolTerm
-        fields = ["name", "date_start", "date_end"]
-
-
 class DashboardWidgetOrderForm(ExtensibleForm):
     pk = forms.ModelChoiceField(
         queryset=None,
diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js
index 6a980fc8dab2fea0b74fec8ef042de413db35bee..567f9a196f61052ff0481c1f975a6c0d2433964e 100644
--- a/aleksis/core/frontend/app/dateTimeFormats.js
+++ b/aleksis/core/frontend/app/dateTimeFormats.js
@@ -26,6 +26,10 @@ const dateTimeFormats = {
       hour: "numeric",
       minute: "numeric",
     },
+    shortTime: {
+      hour: "numeric",
+      minute: "numeric",
+    },
   },
   de: {
     short: {
@@ -53,6 +57,10 @@ const dateTimeFormats = {
       hour: "numeric",
       minute: "numeric",
     },
+    shortTime: {
+      hour: "numeric",
+      minute: "numeric",
+    },
   },
 };
 
diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js
index e2d4439a6dac88f00af6d7ff57d8bda0b963ea40..56eb8effa2da7cd552ad4aed5455f4acd7911df6 100644
--- a/aleksis/core/frontend/app/vuetify.js
+++ b/aleksis/core/frontend/app/vuetify.js
@@ -10,9 +10,10 @@ const vuetifyOpts = {
   icons: {
     iconfont: "mdi", // default - only for display purposes
     values: {
-      cancel: "mdi-close-circle-outline",
-      delete: "mdi-close-circle-outline",
-      success: "mdi-check-circle-outline",
+      cancel: "mdi-close",
+      delete: "mdi-close", // Not a trashcan due to vuetify using this icon inside chips for closing etc.
+      deleteContent: "mdi-delete-outline",
+      success: "mdi-check",
       info: "mdi-information-outline",
       warning: "mdi-alert-outline",
       error: "mdi-alert-octagon-outline",
@@ -22,6 +23,11 @@ const vuetifyOpts = {
       checkboxIndeterminate: "mdi-minus-box-outline",
       edit: "mdi-pencil-outline",
       preferences: "mdi-cog-outline",
+      save: "mdi-content-save-outline",
+      search: "mdi-magnify",
+      filterEmpty: "mdi-filter-outline",
+      filterSet: "mdi-filter",
+      send: "mdi-send-outline",
     },
   },
 };
diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
index a0521f07ab974428f9bc2cfe0722874d485c6c54..6dc73749d8bb716bd32089e11afe36f6d93455c9 100644
--- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue
+++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
@@ -24,7 +24,6 @@
     :src="iFrameSrc"
     :height="iFrameHeight + 'px'"
     class="iframe-fullsize"
-    @load="load"
     ref="contentIFrame"
   ></iframe>
 </template>
@@ -132,6 +131,9 @@ export default {
     },
   },
   mounted() {
+    this.$refs.contentIFrame.addEventListener("load", (e) => {
+      this.load();
+    });
     this.iFrameSrc = "/django" + this.$route.path + this.queryString;
   },
   name: "LegacyBaseTemplate",
diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue
index f650517ae88d122ea6684de14819c4a5f00cc241..52e2801f3953024fd5071679b92ff1a1bb716f01 100644
--- a/aleksis/core/frontend/components/app/App.vue
+++ b/aleksis/core/frontend/components/app/App.vue
@@ -315,4 +315,9 @@ export default {
 };
 </script>
 
-<style scoped></style>
+<style>
+div[aria-required="true"] .v-input .v-label::after {
+  content: " *";
+  color: red;
+}
+</style>
diff --git a/aleksis/core/frontend/components/generic/InlineCRUDList.vue b/aleksis/core/frontend/components/generic/InlineCRUDList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8a93ff8cc6c7f3a3a0b1f7bbe477f7dae1641b59
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/InlineCRUDList.vue
@@ -0,0 +1,717 @@
+<template>
+  <div>
+    <delete-dialog
+      v-if="deletionEnabled"
+      :gql-mutation="gqlDeleteMutation"
+      :gql-query="$apollo.queries.items"
+      v-model="deletionDialog"
+      :item="itemToDelete"
+      @input="handleDeleteDone"
+      :item-attribute="itemTitleAttribute"
+    />
+
+    <delete-multiple-dialog
+      v-if="multipleDeletionEnabled"
+      :gql-mutation="gqlDeleteMultipleMutation"
+      :gql-query="$apollo.queries.items"
+      :items="itemsToDelete"
+      v-model="multipleDeletionDialog"
+      @input="handleDeleteDone"
+      :item-attribute="itemTitleAttribute"
+    />
+
+    <v-form v-model="valid">
+      <v-data-table
+        :headers="tableHeaders"
+        :items="editableItems"
+        :loading="$apollo.loading"
+        :class="elevationClass"
+        :items-per-page="15"
+        :search="search"
+        :sort-by.sync="sortBy"
+        :sort-desc.sync="sortDesc"
+        multi-sort
+        @update:sort-by="handleSortChange"
+        @update:sort-desc="handleSortChange"
+        :show-select="generatedActions.length > 0"
+        selectable-key="canDelete"
+        @item-selected="handleItemSelected"
+        @toggle-select-all="handleToggleAll"
+        @current-items="checkSelectAll"
+      >
+        <template #top>
+          <v-toolbar flat class="height-fit child-height-fit">
+            <v-row class="flex-wrap gap align-baseline pt-4">
+              <v-toolbar-title class="d-flex flex-wrap w-100 gap">
+                <filter-button
+                  class="my-1 button-40"
+                  :num-filters="numFilters"
+                  v-if="filter"
+                  @click="requestFilter"
+                  @clear="clearFilters"
+                />
+
+                <filter-dialog
+                  v-model="filterDialog"
+                  :filters="filters"
+                  @filters="handleFiltersChanged"
+                >
+                  <template #default="slotProps">
+                    <slot
+                      name="filters"
+                      v-if="filter"
+                      v-bind="slotProps"
+                    ></slot>
+                  </template>
+                </filter-dialog>
+
+                <div class="my-1">
+                  <v-text-field
+                    v-model="search"
+                    type="search"
+                    clearable
+                    flat
+                    filled
+                    hide-details
+                    single-line
+                    prepend-inner-icon="$search"
+                    dense
+                    outlined
+                    :label="$t('actions.search')"
+                  ></v-text-field>
+                </div>
+
+                <div
+                  v-if="generatedActions.length > 0 && selectedItems.length > 0"
+                  class="my-1"
+                >
+                  <v-autocomplete
+                    auto-select-first
+                    clearable
+                    :items="generatedActions"
+                    v-model="selectedAction"
+                    return-object
+                    :label="$t('actions.select_action')"
+                    item-text="name"
+                    outlined
+                    dense
+                    :hint="
+                      $tc('selection.num_items_selected', selectedItems.length)
+                    "
+                    persistent-hint
+                    append-outer-icon="$send"
+                    @click:append-outer="handleAction"
+                  >
+                    <template #item="{ item, attrs, on }">
+                      <v-list-item dense v-bind="attrs" v-on="on">
+                        <v-list-item-icon v-if="item.icon">
+                          <v-icon>{{ item.icon }}</v-icon>
+                        </v-list-item-icon>
+                        <v-list-item-content>
+                          <v-list-item-title>{{ item.name }}</v-list-item-title>
+                        </v-list-item-content>
+                      </v-list-item>
+                    </template>
+                  </v-autocomplete>
+                </div>
+              </v-toolbar-title>
+
+              <v-spacer
+                class="flex-grow-0 flex-sm-grow-1 mx-n1 mx-sm-0"
+              ></v-spacer>
+              <slot
+                v-if="!editMode && showCreate"
+                name="createComponent"
+                :attrs="{
+                  value: createMode,
+                  getCreateData: getCreateData,
+                  defaultItem: defaultItem,
+                  gqlQuery: gqlQuery,
+                  gqlCreateMutation: gqlCreateMutation,
+                  gqlPatchMutation: gqlPatchMutation,
+                  isCreate: true,
+                  fields: editableHeaders,
+                  createItemI18nKey: createItemI18nKey,
+                }"
+                :on="{
+                  input: (i) => (i ? requestCreate() : null),
+                  cancel: cancelCreate,
+                  save: handleCreateDone,
+                  error: handleError,
+                }"
+                :create-mode="createMode"
+                :form-field-slot-name="formFieldSlotName"
+              >
+                <dialog-object-form
+                  v-model="createMode"
+                  :get-create-data="getCreateData"
+                  :default-item="defaultItem"
+                  :gql-create-mutation="gqlCreateMutation"
+                  :gql-patch-mutation="gqlPatchMutation"
+                  :is-create="true"
+                  :fields="editableHeaders"
+                  :create-item-i18n-key="createItemI18nKey"
+                  @cancel="cancelCreate"
+                  @save="handleCreateDone"
+                  @error="handleError"
+                >
+                  <template #activator="{ props }">
+                    <create-button
+                      color="secondary"
+                      @click="requestCreate"
+                      :disabled="createMode"
+                    />
+                  </template>
+
+                  <template
+                    v-for="header in editableHeaders"
+                    #[formFieldSlotName(header)]="{ item, isCreate, on, attrs }"
+                  >
+                    <slot
+                      :name="formFieldSlotName(header)"
+                      :attrs="attrs"
+                      :on="on"
+                      :item="item"
+                      :is-create="isCreate"
+                    />
+                  </template>
+                </dialog-object-form>
+              </slot>
+              <edit-button
+                v-if="!editMode && editingEnabled"
+                @click="requestEdit"
+                :disabled="createMode"
+              />
+              <cancel-button v-if="editMode" @click="cancelEdit" />
+              <save-button
+                v-if="editMode"
+                @click="saveEdit"
+                :loading="loading"
+                :disabled="!valid"
+              />
+            </v-row>
+          </v-toolbar>
+        </template>
+
+        <template
+          v-for="(header, idx) in headers"
+          #[tableItemSlotName(header)]="{ item }"
+        >
+          <v-scroll-x-transition mode="out-in" :key="idx">
+            <span key="value" v-if="!editMode || header.disableEdit">
+              <slot :name="header.value" :item="item">{{
+                item[header.value]
+              }}</slot>
+            </span>
+            <span key="field" v-else-if="editMode">
+              <slot
+                :name="header.value + '.field'"
+                :item="item"
+                :is-create="false"
+                :attrs="buildAttrs(item[header.value])"
+                :on="buildOn(dynamicSetter(item, header.value))"
+              >
+                <v-text-field
+                  filled
+                  dense
+                  hide-details="auto"
+                  v-model="item[header.value]"
+                ></v-text-field>
+              </slot>
+            </span>
+          </v-scroll-x-transition>
+        </template>
+
+        <!-- eslint-disable-next-line vue/valid-v-slot -->
+        <template #item.actions="{ item }">
+          <slot name="actions" :item="item" />
+          <v-btn
+            v-if="'canDelete' in item && item.canDelete"
+            icon
+            :title="$t(`actions.delete`)"
+            color="error"
+            @click="handleDeleteClick(item)"
+          >
+            <v-icon>$deleteContent</v-icon>
+          </v-btn>
+        </template>
+      </v-data-table>
+    </v-form>
+
+    <closable-snackbar :color="snackbarState" v-model="snackbar">
+      {{ snackbarText }}
+    </closable-snackbar>
+  </div>
+</template>
+
+<script>
+import CreateButton from "./buttons/CreateButton.vue";
+import EditButton from "./buttons/EditButton.vue";
+import SaveButton from "./buttons/SaveButton.vue";
+import CancelButton from "./buttons/CancelButton.vue";
+import DeleteDialog from "./dialogs/DeleteDialog.vue";
+import DeleteMultipleDialog from "./dialogs/DeleteMultipleDialog.vue";
+import DialogObjectForm from "./dialogs/DialogObjectForm.vue";
+import ClosableSnackbar from "./dialogs/ClosableSnackbar.vue";
+import FilterButton from "./buttons/FilterButton.vue";
+import FilterDialog from "./dialogs/FilterDialog.vue";
+
+export default {
+  name: "InlineCRUDList",
+  components: {
+    FilterDialog,
+    FilterButton,
+    ClosableSnackbar,
+    DeleteDialog,
+    DeleteMultipleDialog,
+    DialogObjectForm,
+    CancelButton,
+    SaveButton,
+    EditButton,
+    CreateButton,
+  },
+
+  apollo: {
+    items() {
+      return {
+        query: this.gqlQuery,
+        variables() {
+          return {
+            orderBy: this.gqlOrderBy,
+            filters: this.filterString,
+          };
+        },
+        error: function (error) {
+          this.handleError(error);
+        },
+        result: function (data) {
+          this.editableItems = data.data
+            ? this.getGqlData(JSON.parse(JSON.stringify(data.data.items)))
+            : [];
+        },
+      };
+    },
+  },
+  data() {
+    return {
+      editMode: false,
+      createMode: false,
+      loading: false,
+      createModel: {},
+      editableItems: [],
+      snackbar: false,
+      snackbarText: null,
+      snackbarState: "success",
+      valid: false,
+      deletionDialog: false,
+      multipleDeletionDialog: false,
+      itemToDelete: null,
+      itemsToDelete: [],
+      search: "",
+      filterDialog: false,
+      filters: {},
+      filterString: "{}",
+      sortBy: [],
+      sortDesc: [],
+      gqlOrderBy: [],
+      selectedAction: null,
+      selectedItems: [],
+      allSelected: false,
+    };
+  },
+  computed: {
+    tableHeaders() {
+      return this.headers
+        .concat(
+          this.deletionEnabled
+            ? [
+                {
+                  text: this.$t("actions.title"),
+                  value: "actions",
+                  sortable: false,
+                  align: "right",
+                },
+              ]
+            : []
+        )
+        .filter((header) => this.hiddenColumns.indexOf(header.value) === -1);
+    },
+    editableHeaders() {
+      return this.headers.filter((header) => !header.disableEdit);
+    },
+    elevationClass() {
+      return this.elevated ? "elevation-2" : "";
+    },
+    editingEnabled() {
+      return (
+        this.gqlPatchMutation && this.items && this.items.some((i) => i.canEdit)
+      );
+    },
+    deletionEnabled() {
+      return (
+        this.gqlDeleteMutation &&
+        this.items &&
+        this.items.some((i) => i.canDelete)
+      );
+    },
+    multipleDeletionEnabled() {
+      return (
+        this.multipleDeletion &&
+        this.gqlDeleteMultipleMutation &&
+        this.items &&
+        this.items.some((i) => i.canDelete)
+      );
+    },
+    numFilters() {
+      // This needs to use the json string, as vue reactivity doesn't work for objects with dynamic properties
+      return Object.keys(JSON.parse(this.filterString)).length;
+    },
+    generatedActions() {
+      if (!this.multipleDeletionEnabled) {
+        return this.actions;
+      }
+      return [
+        ...this.actions,
+        {
+          name: this.$t("actions.delete"),
+          icon: "$deleteContent",
+          handler: (items) => {
+            this.itemsToDelete = items;
+            this.multipleDeletionDialog = true;
+          },
+          clearSelection: true,
+        },
+      ];
+    },
+  },
+  props: {
+    i18nKey: {
+      type: String,
+      required: true,
+    },
+    createItemI18nKey: {
+      type: String,
+      required: false,
+      default: "actions.create",
+    },
+    createSuccessMessageKey: {
+      type: String,
+      required: false,
+      default: "status.object_create_success",
+    },
+    gqlQuery: {
+      type: Object,
+      required: true,
+    },
+    gqlCreateMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlPatchMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlDeleteMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlDeleteMultipleMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    headers: {
+      type: Array,
+      required: true,
+    },
+    itemTitleAttribute: {
+      type: String,
+      required: false,
+      default: "name",
+    },
+    defaultItem: {
+      type: Object,
+      required: true,
+    },
+    showCreate: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    elevated: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+    hiddenColumns: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    getGqlData: {
+      type: Function,
+      required: false,
+      default: (item) => item,
+    },
+    getPatchData: {
+      type: Function,
+      required: false,
+      default: (items, headers) => {
+        return items.map((item) => {
+          let dto = {};
+          headers.map((header) => (dto[header.value] = item[header.value]));
+          return dto;
+        });
+      },
+    },
+    getCreateData: {
+      type: Function,
+      required: false,
+      default: (item) => item,
+    },
+    filter: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    actions: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    multipleDeletion: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+  },
+  methods: {
+    requestCreate() {
+      if (this.loading) return;
+
+      this.createMode = true;
+      this.editMode = false;
+    },
+    requestEdit() {
+      if (this.loading) return;
+
+      this.editMode = true;
+      this.createMode = false;
+    },
+    saveEdit() {
+      this.loading = true;
+
+      if (!this.editableItems || !this.editingEnabled) return;
+
+      this.$apollo
+        .mutate({
+          mutation: this.gqlPatchMutation,
+          variables: {
+            input: this.getPatchData(
+              this.editableItems,
+              this.headers.concat({ title: "id", value: "id" })
+            ),
+          },
+        })
+        .then((data) => {
+          this.items = data.data.batchMutation.items;
+          this.editableItems = this.getGqlData(data.data.batchMutation.items);
+
+          this.handleSuccess("status.saved");
+        })
+        .catch((error) => {
+          this.handleError(error);
+        })
+        .finally(() => {
+          this.loading = false;
+          this.editMode = false;
+        });
+    },
+    cancelEdit() {
+      this.editMode = false;
+      this.editableItems = this.getGqlData(
+        JSON.parse(JSON.stringify(this.items))
+      );
+    },
+    saveCreate() {
+      if (!this.gqlCreateMutation) return;
+
+      this.loading = true;
+      this.$apollo
+        .mutate({
+          mutation: this.gqlCreateMutation,
+          variables: {
+            input: this.createModel,
+          },
+        })
+        .then((data) => {
+          this.$apollo.queries.items.refetch();
+          this.createModel = {};
+        })
+        .catch((error) => {
+          this.handleError(error);
+        })
+        .finally(() => {
+          this.loading = false;
+          this.createMode = false;
+        });
+    },
+    cancelCreate() {
+      this.createMode = false;
+      this.createModel = {};
+    },
+    tableItemSlotName(headerEntry) {
+      return "item." + headerEntry.value;
+    },
+    formFieldSlotName(headerEntry) {
+      return headerEntry.value + ".field";
+    },
+    dynamicSetter(item, fieldName) {
+      return (value) => {
+        this.$set(item, fieldName, value);
+      };
+    },
+    handleError(error) {
+      console.error(error);
+      if (error instanceof String) {
+        // error is a translation key or simply a string
+        this.snackbarText = this.$t(error);
+      } else if (error instanceof Object && error.message) {
+        this.snackbarText = error.message;
+      } else {
+        this.snackbarText = this.$t("graphql.snackbar_error_message");
+      }
+      this.snackbarState = "error";
+      this.snackbar = true;
+    },
+    handleSuccess(success) {
+      this.snackbarText = this.$t(
+        success || "graphql.snackbar_success_message"
+      );
+
+      this.snackbarState = "success";
+      this.snackbar = true;
+    },
+    handleDeleteClick(item) {
+      if (!item) {
+        console.warn("Delete handler called without item parameter");
+        return;
+      }
+
+      this.itemToDelete = item;
+      this.deletionDialog = true;
+    },
+    handleDeleteDone() {
+      this.itemToDelete = null;
+      this.itemsToDelete = [];
+    },
+    handleCreateDone() {
+      this.$apollo.queries.items.refetch();
+      this.createMode = false;
+    },
+    requestFilter() {
+      if (this.filter) {
+        this.filterDialog = true;
+      }
+    },
+    handleFiltersChanged(event) {
+      this.filters = event;
+      this.filterString = JSON.stringify(this.filters);
+    },
+    clearFilters() {
+      this.handleFiltersChanged({});
+    },
+    buildAttrs(value) {
+      return {
+        dense: true,
+        filled: true,
+        hideDetails: "auto",
+        value: value,
+        inputValue: value,
+      };
+    },
+    buildOn(setter) {
+      return {
+        input: setter,
+        change: setter,
+      };
+    },
+    snakeCase(string) {
+      return string
+        .replace(/\W+/g, " ")
+        .split(/ |\B(?=[A-Z])/)
+        .map((word) => word.toLowerCase())
+        .join("_");
+    },
+    orderKey(value, desc) {
+      const key =
+        this.headers.find((header) => header.value === value).orderKey ||
+        this.snakeCase(value);
+      return (desc ? "-" : "") + key;
+    },
+    handleSortChange() {
+      this.gqlOrderBy = this.sortBy.map((value, key) =>
+        this.orderKey(value, this.sortDesc[key])
+      );
+    },
+    handleItemSelected({ item, value }) {
+      if (value) {
+        this.selectedItems.push(item);
+      } else {
+        const index = this.selectedItems.indexOf(item);
+        if (index >= 0) {
+          this.selectedItems.splice(index, 1);
+        }
+      }
+    },
+    handleToggleAll({ items, value }) {
+      if (value) {
+        // There is a bug in vuetify: items contains all elements, even those that aren't selectable
+        this.selectedItems = items.filter((item) => item.canDelete || false);
+      } else {
+        this.selectedItems = [];
+      }
+      this.allSelected = value;
+    },
+    checkSelectAll(newItems) {
+      if (this.allSelected) {
+        this.handleToggleAll({
+          items: newItems,
+          value: true,
+        });
+      }
+    },
+    handleAction() {
+      if (this.selectedAction) {
+        this.selectedAction.handler(this.selectedItems);
+
+        if (this.selectedAction.clearSelection) {
+          this.selectedItems = [];
+        }
+
+        this.selectedAction = null;
+      }
+    },
+  },
+  mounted() {
+    this.$setToolBarTitle(this.$t(`${this.i18nKey}.title_plural`), null);
+  },
+};
+</script>
+
+<style>
+.gap {
+  gap: 0.5rem;
+}
+.height-fit,
+.child-height-fit > * {
+  height: fit-content !important;
+}
+
+.button-40 {
+  min-height: 40px;
+}
+</style>
diff --git a/aleksis/core/frontend/components/generic/ObjectCRUDList.vue b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..be792e3d825ec0d98c54a21b81f9b3a385f82203
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue
@@ -0,0 +1,331 @@
+<script setup>
+import CreateButton from "./buttons/CreateButton.vue";
+import DialogObjectForm from "./dialogs/DialogObjectForm.vue";
+</script>
+
+<template>
+  <v-card>
+    <v-data-iterator
+      :items="items"
+      :items-per-page="itemsPerPage"
+      :loading="$apollo.queries.items.loading"
+      hide-default-footer
+    >
+      <template #loading>
+        <slot name="loading">
+          <v-skeleton-loader
+            type="card-heading, list-item-avatar-two-line@3, actions"
+          />
+        </slot>
+      </template>
+
+      <template #no-data>
+        <v-card-text>{{ $t(noItemsI18nKey) }}</v-card-text>
+      </template>
+
+      <template #header>
+        <v-card-title>{{ title }}</v-card-title>
+      </template>
+
+      <template #default="props">
+        <slot
+          v-if="items.length"
+          name="iteratorContent"
+          :items="props.items"
+          :editing-enabled="editingEnabled"
+          :deletion-enabled="deletionEnabled"
+          :handle-edit="handleEdit"
+          :handle-delete="handleDelete"
+        >
+          <v-list>
+            <template v-for="(item, index) in items">
+              <v-list-item :key="item.id">
+                <v-list-item-avatar>
+                  <slot
+                    name="listIteratorItemAvatar"
+                    :item="item"
+                    :index="index"
+                  />
+                </v-list-item-avatar>
+                <v-list-item-content>
+                  <slot
+                    name="listIteratorItemContent"
+                    :item="item"
+                    :index="index"
+                  >
+                    <v-list-item-title>
+                      {{ item.name }}
+                    </v-list-item-title>
+                  </slot>
+                </v-list-item-content>
+                <v-list-item-action>
+                  <v-btn
+                    v-if="editingEnabled && item.canEdit"
+                    icon
+                    @click="handleEdit(item)"
+                  >
+                    <v-icon>mdi-pencil-outline</v-icon>
+                  </v-btn>
+                  <v-btn
+                    v-if="deletionEnabled && item.canDelete"
+                    icon
+                    @click="handleDelete(item)"
+                  >
+                    <v-icon>mdi-delete-outline</v-icon>
+                  </v-btn>
+                </v-list-item-action>
+              </v-list-item>
+              <v-divider
+                v-if="index < items.length - 1"
+                :key="index"
+                inset
+              ></v-divider>
+            </template>
+          </v-list>
+        </slot>
+      </template>
+
+      <template #footer>
+        <v-card-actions>
+          <slot
+            v-if="creatingEnabled || editingEnabled"
+            name="createComponent"
+            :attrs="{
+              value: objectFormModel,
+              defaultItem: defaultItem,
+              editItem: editItem,
+              gqlCreateMutation: gqlCreateMutation,
+              gqlPatchMutation: gqlPatchMutation,
+              isCreate: isCreate,
+              fields: fields,
+              getCreateData: getCreateData,
+              createItemI18nKey: createItemI18nKey,
+            }"
+            :on="{
+              input: (i) => (objectFormModel = i),
+              cancel: () => (objectFormModel = false),
+              save: handleCreateDone,
+              error: handleError,
+            }"
+          >
+            <dialog-object-form
+              v-model="objectFormModel"
+              :get-create-data="getCreateData"
+              :get-patch-data="getPatchData"
+              :default-item="defaultItem"
+              :edit-item="editItem"
+              :force-model-item-update="true"
+              :gql-create-mutation="gqlCreateMutation"
+              :gql-patch-mutation="gqlPatchMutation"
+              :is-create="isCreate"
+              :fields="fields"
+              :create-item-i18n-key="createItemI18nKey"
+              @cancel="objectFormModel = false"
+              @save="handleCreateDone"
+              @error="handleError"
+            >
+              <template #activator="{ props }" v-if="creatingEnabled">
+                <create-button
+                  @click="handleCreate"
+                  :disabled="objectFormModel"
+                />
+              </template>
+
+              <template
+                v-for="field in fields"
+                #[formFieldSlotName(field)]="{ item, isCreate, on, attrs }"
+              >
+                <slot
+                  :name="formFieldSlotName(field)"
+                  :attrs="attrs"
+                  :on="on"
+                  :item="item"
+                  :is-create="isCreate"
+                />
+              </template>
+            </dialog-object-form>
+          </slot>
+        </v-card-actions>
+      </template>
+    </v-data-iterator>
+  </v-card>
+</template>
+
+<script>
+export default {
+  name: "ObjectCRUDList",
+  props: {
+    titleI18nKey: {
+      type: String,
+      required: false,
+      default: "",
+    },
+    titleString: {
+      type: String,
+      required: false,
+      default: "",
+    },
+    noItemsI18nKey: {
+      type: String,
+      required: true,
+    },
+    createItemI18nKey: {
+      type: String,
+      required: false,
+      default: "actions.create",
+    },
+    fields: {
+      type: Array,
+      required: false,
+      default: undefined,
+    },
+    defaultItem: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    getGqlData: {
+      type: Function,
+      required: false,
+      default: (data) => data.items,
+    },
+    gqlQuery: {
+      type: Object,
+      required: true,
+    },
+    gqlVariables: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlCreateMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlPatchMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlDeleteMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    getCreateData: {
+      type: Function,
+      required: false,
+      default: (item) => item,
+    },
+    getPatchData: {
+      type: Function,
+      required: false,
+      default: (item) => {
+        let { id, __typename, ...patchItem } = item;
+        return patchItem;
+      },
+    },
+    itemsPerPage: {
+      type: Number,
+      required: false,
+      default: 5,
+    },
+  },
+  components: {
+    CreateButton,
+  },
+  data() {
+    return {
+      objectFormModel: false,
+      editItem: undefined,
+      isCreate: true,
+    };
+  },
+  apollo: {
+    items() {
+      return {
+        query: this.gqlQuery,
+        variables() {
+          if (this.gqlVariables) {
+            return this.gqlVariables;
+          }
+          return {};
+        },
+        error: function (error) {
+          this.handleError(error);
+        },
+        update(data) {
+          return this.getGqlData(data);
+        },
+      };
+    },
+  },
+  methods: {
+    handleCreate() {
+      this.editItem = undefined;
+      this.isCreate = true;
+      this.objectFormModel = true;
+    },
+    handleEdit(item) {
+      if (!item || !this.editingEnabled) {
+        return;
+      }
+
+      this.editItem = item;
+      this.isCreate = false;
+      this.objectFormModel = true;
+    },
+    handleDelete() {},
+    handleCreateDone() {
+      this.$apollo.queries.items.refetch();
+    },
+    handleError(error) {
+      console.error(error);
+      let snackbarText = "";
+      if (error instanceof String) {
+        // error is a translation key or simply a string
+        snackbarText = this.$t(error);
+      } else if (error instanceof Object && error.message) {
+        snackbarText = error.message;
+      } else {
+        snackbarText = this.$t("graphql.snackbar_error_message");
+      }
+      this.$root.snackbarItems.push({
+        id: crypto.randomUUID(),
+        timeout: 5000,
+        messageKey: snackbarText,
+        color: "error",
+      });
+    },
+    formFieldSlotName(headerEntry) {
+      return headerEntry.value + ".field";
+    },
+  },
+  computed: {
+    creatingEnabled() {
+      return this.gqlCreateMutation && this.fields && this.defaultItem;
+    },
+    editingEnabled() {
+      return (
+        this.gqlPatchMutation &&
+        this.fields &&
+        this.items &&
+        this.items.some((i) => i.canEdit)
+      );
+    },
+    deletionEnabled() {
+      return (
+        this.gqlDeleteMutation &&
+        this.items &&
+        this.items.some((i) => i.canDelete)
+      );
+    },
+    title() {
+      return this.titleI18nKey ? this.$t(this.titleI18nKey) : this.titleString;
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5dd63039fe22d0d57534eb2f62f039ea101f2897
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue
@@ -0,0 +1,29 @@
+<template>
+  <v-btn v-bind="{ ...$props, ...$attrs }" @click="$emit('click', $event)">
+    <slot>
+      <v-icon v-if="iconText" left>{{ iconText }}</v-icon>
+      <span v-t="i18nKey" />
+    </slot>
+  </v-btn>
+</template>
+
+<script>
+export default {
+  name: "BaseButton",
+  inheritAttrs: true,
+  extends: "v-btn",
+  props: {
+    i18nKey: {
+      type: String,
+      required: true,
+    },
+    iconText: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/buttons/CancelButton.vue b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2d12aa4576106a3bce51f77bfd8e0d401f2ab16a
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue
@@ -0,0 +1,25 @@
+<script>
+import SecondaryActionButton from "./SecondaryActionButton.vue";
+
+export default {
+  name: "CancelButton",
+  extends: SecondaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$cancel",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.cancel",
+    },
+    color: {
+      type: String,
+      required: false,
+      default: "error",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/CreateButton.vue b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6a03704c176f9bfaf952a1529cbad4ed6c996846
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue
@@ -0,0 +1,20 @@
+<script>
+import PrimaryActionButton from "./PrimaryActionButton.vue";
+
+export default {
+  name: "CreateButton",
+  extends: PrimaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$plus",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.create",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d742345b799e20d860cca01796f75d85e0d0d6a5
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue
@@ -0,0 +1,20 @@
+<script>
+import PrimaryActionButton from "./PrimaryActionButton.vue";
+
+export default {
+  name: "DeleteButton",
+  extends: PrimaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$delete",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.delete",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/EditButton.vue b/aleksis/core/frontend/components/generic/buttons/EditButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..45c6918bfb18c19796e5a6e3fc8ccca9e1542b65
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/EditButton.vue
@@ -0,0 +1,20 @@
+<script>
+import PrimaryActionButton from "./PrimaryActionButton.vue";
+
+export default {
+  name: "EditButton",
+  extends: PrimaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$edit",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.edit",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5b7a2435dcd11b2ee29f178f34c02a0138318453
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue
@@ -0,0 +1,55 @@
+<template>
+  <secondary-action-button
+    v-bind="$attrs"
+    v-on="$listeners"
+    :i18n-key="i18nKey"
+  >
+    <v-icon v-if="icon" left>{{ icon }}</v-icon>
+    <v-badge color="secondary" :value="numFilters" :content="numFilters" inline>
+      <span v-t="i18nKey" />
+    </v-badge>
+    <v-btn
+      icon
+      @click.stop="$emit('clear')"
+      small
+      v-if="numFilters"
+      class="mr-n1"
+    >
+      <v-icon>$clear</v-icon>
+    </v-btn>
+  </secondary-action-button>
+</template>
+
+<script>
+import SecondaryActionButton from "./SecondaryActionButton.vue";
+
+export default {
+  name: "FilterButton",
+  components: { SecondaryActionButton },
+  extends: SecondaryActionButton,
+  computed: {
+    icon() {
+      return this.hasFilters || this.numFilters > 0
+        ? "$filterSet"
+        : "$filterEmpty";
+    },
+  },
+  props: {
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.filter",
+    },
+    hasFilters: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    numFilters: {
+      type: Number,
+      required: false,
+      default: 0,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b33e8fe073a0e8b62b03fe593564b91a8e375ebb
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue
@@ -0,0 +1,19 @@
+<script>
+import BaseButton from "./BaseButton.vue";
+export default {
+  name: "PrimaryActionButton",
+  components: { BaseButton },
+  extends: BaseButton,
+  props: {
+    i18nKey: {
+      type: String,
+      required: true,
+    },
+    color: {
+      type: String,
+      required: false,
+      default: "primary",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/SaveButton.vue b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..16380f83f56c965c1eacbc66f541c0020fedb6fd
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue
@@ -0,0 +1,20 @@
+<script>
+import PrimaryActionButton from "./PrimaryActionButton.vue";
+
+export default {
+  name: "SaveButton",
+  extends: PrimaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$save",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.save",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..209d10d102e4f45a26efa72e03c1d00b39378caf
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue
@@ -0,0 +1,24 @@
+<script>
+import BaseButton from "./BaseButton.vue";
+export default {
+  name: "SecondaryActionButton",
+  components: { BaseButton },
+  extends: BaseButton,
+  props: {
+    i18nKey: {
+      type: String,
+      required: true,
+    },
+    color: {
+      type: String,
+      required: false,
+      default: "secondary",
+    },
+    outlined: {
+      type: Boolean,
+      required: false,
+      default: true,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue
new file mode 100644
index 0000000000000000000000000000000000000000..16fd418c34bbc3f3ffe3549e3a86635ffd45ec11
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue
@@ -0,0 +1,24 @@
+<template>
+  <v-snackbar v-bind="$attrs" v-on="$listeners">
+    <slot />
+    <template #action="{ attrs }">
+      <v-btn v-bind="attrs" @click="close()" icon>
+        <v-icon>$close</v-icon>
+      </v-btn>
+    </template>
+  </v-snackbar>
+</template>
+
+<script>
+export default {
+  name: "ClosableSnackbar",
+  extends: "v-snackbar",
+  methods: {
+    close() {
+      this.$emit("input", false);
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue
index a56e3960cd1ae29e60f966a44ae69adeb0332949..4824d2e8f6eaf3c5046de9611b6bf47458ceb195 100644
--- a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue
+++ b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue
@@ -1,3 +1,9 @@
+<script setup>
+import CancelButton from "../buttons/CancelButton.vue";
+import DeleteButton from "../buttons/DeleteButton.vue";
+import MobileFullscreenDialog from "./MobileFullscreenDialog.vue";
+</script>
+
 <template>
   <ApolloMutation
     v-if="dialogOpen"
@@ -7,39 +13,29 @@
     @done="close(true)"
   >
     <template #default="{ mutate, loading, error }">
-      <v-dialog v-model="dialogOpen" max-width="500px">
-        <v-card>
-          <v-card-title class="text-h5">
-            <slot name="title">
-              {{ $t("actions.confirm_deletion") }}
-            </slot>
-          </v-card-title>
-          <v-card-text>
-            <slot name="body">
-              <p class="text-body-1">{{ nameOfObject }}</p>
+      <mobile-fullscreen-dialog v-model="dialogOpen">
+        <template #title>
+          <slot name="title">
+            {{ $t("actions.confirm_deletion") }}
+          </slot>
+        </template>
+        <template #content>
+          <slot name="body">
+            <p class="text-body-1">{{ nameOfObject }}</p>
+          </slot>
+        </template>
+        <template #actions>
+          <cancel-button @click="close(false)" :disabled="loading">
+            <slot name="cancelContent">
+              <v-icon left>$cancel</v-icon>
+              {{ $t("actions.cancel") }}
             </slot>
-          </v-card-text>
-          <v-card-actions>
-            <v-spacer></v-spacer>
-            <v-btn text @click="close(false)" :disabled="loading">
-              <slot name="cancelContent">
-                {{ $t("actions.cancel") }}
-              </slot>
-            </v-btn>
-            <v-btn
-              color="error"
-              text
-              @click="mutate"
-              :loading="loading"
-              :disabled="loading"
-            >
-              <slot name="deleteContent">
-                {{ $t("actions.delete") }}
-              </slot>
-            </v-btn>
-          </v-card-actions>
-        </v-card>
-      </v-dialog>
+          </cancel-button>
+          <delete-button @click="mutate" :loading="loading" :disabled="loading">
+            <slot name="deleteContent" />
+          </delete-button>
+        </template>
+      </mobile-fullscreen-dialog>
       <v-snackbar :value="error !== null">
         {{ error }}
 
@@ -71,16 +67,27 @@ export default {
         this.$emit("input", val);
       },
     },
+    query() {
+      if ("options" in this.gqlQuery) {
+        return {
+          ...this.gqlQuery.options,
+          variables: JSON.parse(this.gqlQuery.previousVariablesJson),
+        };
+      }
+      return { query: this.gqlQuery };
+    },
   },
   methods: {
     update(store) {
+      this.$emit("update", store);
+
       if (!this.gqlQuery) {
         // There is no GraphQL query to update
         return;
       }
 
       // Read the data from cache for query
-      const storedData = store.readQuery({ query: this.gqlQuery });
+      const storedData = store.readQuery(this.query);
 
       if (!storedData) {
         // There are no data in the cache yet
@@ -96,12 +103,19 @@ export default {
       storedData[storedDataKey].splice(index, 1);
 
       // Write data back to the cache
-      store.writeQuery({ query: this.gqlQuery, data: storedData });
+      store.writeQuery({ ...this.query, data: storedData });
     },
     close(success) {
       this.$emit("input", false);
       if (success) {
         this.$emit("success");
+
+        this.$root.snackbarItems.push({
+          id: crypto.randomUUID(),
+          timeout: 5000,
+          messageKey: this.$t(this.deleteSuccessMessageI18nKey),
+          color: "success",
+        });
       } else {
         this.$emit("cancel");
       }
@@ -131,6 +145,11 @@ export default {
       required: false,
       default: null,
     },
+    deleteSuccessMessageI18nKey: {
+      type: String,
+      required: false,
+      default: "status.object_delete_success",
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1e09c18ba6a517a6c053c2faac09e35bbfc62f71
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue
@@ -0,0 +1,161 @@
+<script setup>
+import CancelButton from "../buttons/CancelButton.vue";
+import DeleteButton from "../buttons/DeleteButton.vue";
+import MobileFullscreenDialog from "./MobileFullscreenDialog.vue";
+</script>
+
+<template>
+  <ApolloMutation
+    v-if="dialogOpen"
+    :mutation="gqlMutation"
+    :variables="{ ids: ids }"
+    :update="update"
+    @done="close(true)"
+  >
+    <template #default="{ mutate, loading, error }">
+      <mobile-fullscreen-dialog v-model="dialogOpen">
+        <template #title>
+          <slot name="title">
+            {{ $t("actions.confirm_deletion_multiple") }}
+          </slot>
+        </template>
+        <template #content>
+          <slot name="body">
+            <ul class="text-body-1">
+              <li v-for="(item, idx) in items" :key="idx">
+                {{ nameOfItem(item) }}
+              </li>
+            </ul>
+          </slot>
+        </template>
+        <template #actions>
+          <cancel-button @click="close(false)" :disabled="loading">
+            <slot name="cancelContent">
+              <v-icon left>$cancel</v-icon>
+              {{ $t("actions.cancel") }}
+            </slot>
+          </cancel-button>
+          <delete-button @click="mutate" :loading="loading" :disabled="loading">
+            <slot name="deleteContent" />
+          </delete-button>
+        </template>
+      </mobile-fullscreen-dialog>
+    </template>
+  </ApolloMutation>
+</template>
+
+<script>
+export default {
+  name: "DeleteDialog",
+  computed: {
+    dialogOpen: {
+      get() {
+        return this.value;
+      },
+
+      set(val) {
+        this.$emit("input", val);
+      },
+    },
+    ids() {
+      return this.items.map((item) => item[this.itemId]);
+    },
+    query() {
+      if ("options" in this.gqlQuery) {
+        return {
+          ...this.gqlQuery.options,
+          variables: JSON.parse(this.gqlQuery.previousVariablesJson),
+        };
+      }
+      return { query: this.gqlQuery };
+    },
+  },
+  methods: {
+    update(store) {
+      this.$emit("update", store);
+
+      if (!this.gqlQuery) {
+        // There is no GraphQL query to update
+        return;
+      }
+
+      // Read the data from cache for query
+      const storedData = store.readQuery(this.query);
+
+      if (!storedData) {
+        // There are no data in the cache yet
+        return;
+      }
+
+      const storedDataKey = Object.keys(storedData)[0];
+
+      for (const item of this.items) {
+        console.debug("Removing item from store:", item);
+        // Remove item from stored data
+        const index = storedData[storedDataKey].findIndex(
+          (m) => m.id === item.id
+        );
+        storedData[storedDataKey].splice(index, 1);
+      }
+
+      // Write data back to the cache
+      store.writeQuery({ ...this.query, data: storedData });
+    },
+    close(success) {
+      this.$emit("input", false);
+      if (success) {
+        this.$emit("success");
+
+        this.$root.snackbarItems.push({
+          id: crypto.randomUUID(),
+          timeout: 5000,
+          messageKey: this.$t(this.deleteSuccessMessageI18nKey),
+          color: "success",
+        });
+      } else {
+        this.$emit("cancel");
+      }
+    },
+    nameOfItem(item) {
+      return this.itemAttribute in item || {}
+        ? item[this.itemAttribute]
+        : item.toString();
+    },
+  },
+  props: {
+    value: {
+      type: Boolean,
+      required: true,
+    },
+    items: {
+      type: Array,
+      required: false,
+      default: () => [],
+    },
+    itemAttribute: {
+      type: String,
+      required: false,
+      default: "name",
+    },
+    itemId: {
+      type: String,
+      required: false,
+      default: "id",
+    },
+    gqlMutation: {
+      type: Object,
+      required: true,
+    },
+    gqlQuery: {
+      type: Object,
+      required: false,
+      default: null,
+    },
+    deleteSuccessMessageI18nKey: {
+      type: String,
+      required: false,
+      default: "status.objects_delete_success",
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..90bf452b66cd7c1aa0ab2843e005ef7f82c4e84b
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue
@@ -0,0 +1,250 @@
+<template>
+  <mobile-fullscreen-dialog v-model="dialog" max-width="500px">
+    <template #activator="{ on, attrs }">
+      <slot name="activator" v-bind="{ on, attrs }" />
+    </template>
+
+    <template #title>
+      <slot name="title">
+        <span class="text-h5">{{
+          isCreate ? $t(createItemI18nKey) : $t(editItemI18nKey)
+        }}</span>
+      </slot>
+    </template>
+
+    <template #content>
+      <v-form v-model="valid">
+        <v-container>
+          <v-row>
+            <v-col cols="12" sm="6" v-for="field in fields" :key="field.value">
+              <slot
+                :label="field.text"
+                :name="field.value + '.field'"
+                :attrs="buildAttrs(itemModel, field)"
+                :on="buildOn(dynamicSetter(itemModel, field.value))"
+                :is-create="isCreate"
+                :item="itemModel"
+              >
+                <v-text-field
+                  :label="field.text"
+                  filled
+                  v-model="itemModel[field.value]"
+                ></v-text-field>
+              </slot>
+            </v-col>
+          </v-row>
+        </v-container>
+      </v-form>
+    </template>
+
+    <template #actions>
+      <cancel-button @click="$emit('cancel')" />
+      <save-button @click="save" :loading="loading" :disabled="!valid" />
+    </template>
+  </mobile-fullscreen-dialog>
+</template>
+
+<script>
+import SaveButton from "../buttons/SaveButton.vue";
+import CancelButton from "../buttons/CancelButton.vue";
+import MobileFullscreenDialog from "./MobileFullscreenDialog.vue";
+
+export default {
+  name: "DialogObjectForm",
+  components: {
+    CancelButton,
+    SaveButton,
+    MobileFullscreenDialog,
+  },
+  data() {
+    return {
+      loading: false,
+      valid: false,
+      firstInitDone: false,
+      itemModel: {},
+    };
+  },
+  props: {
+    value: {
+      type: Boolean,
+      default: false,
+    },
+    createItemI18nKey: {
+      type: String,
+      required: false,
+      default: "actions.create",
+    },
+    createSuccessMessageI18nKey: {
+      type: String,
+      required: false,
+      default: "status.object_create_success",
+    },
+    editItemI18nKey: {
+      type: String,
+      required: false,
+      default: "actions.edit",
+    },
+    editSuccessMessageI18nKey: {
+      type: String,
+      required: false,
+      default: "status.object_edit_success",
+    },
+    gqlCreateMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    gqlPatchMutation: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    fields: {
+      type: Array,
+      required: true,
+    },
+    itemTitleAttribute: {
+      type: String,
+      required: false,
+      default: "name",
+    },
+    defaultItem: {
+      type: Object,
+      required: true,
+    },
+    editItem: {
+      type: Object,
+      required: false,
+      default: undefined,
+    },
+    forceModelItemUpdate: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+    getCreateData: {
+      type: Function,
+      required: false,
+      default: (item) => item,
+    },
+    getPatchData: {
+      type: Function,
+      required: false,
+      default: (item) => {
+        let { id, __typename, ...patchItem } = item;
+        return patchItem;
+      },
+    },
+    isCreate: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  computed: {
+    dialog: {
+      get() {
+        return this.value;
+      },
+      set(newValue) {
+        this.$emit("input", newValue);
+      },
+    },
+  },
+  methods: {
+    save() {
+      this.loading = true;
+
+      if (
+        !this.itemModel ||
+        (this.isCreate && !this.gqlCreateMutation) ||
+        (!this.isCreate && !this.gqlPatchMutation)
+      )
+        return;
+
+      let mutation = this.isCreate
+        ? this.gqlCreateMutation
+        : this.gqlPatchMutation;
+
+      let variables = this.isCreate
+        ? { input: this.getCreateData(this.itemModel) }
+        : { input: this.getPatchData(this.itemModel), id: this.itemModel.id };
+
+      this.$apollo
+        .mutate({
+          mutation: mutation,
+          variables: variables,
+          update: (store, data) => {
+            this.$emit(
+              "update",
+              store,
+              data.data[mutation.definitions[0].name.value].item
+            );
+          },
+        })
+        .then((data) => {
+          this.$emit("save", data);
+
+          this.handleSuccess();
+        })
+        .catch((error) => {
+          console.error(error);
+          this.$emit("error", error);
+        })
+        .finally(() => {
+          this.loading = false;
+          this.dialog = false;
+        });
+    },
+    dynamicSetter(item, fieldName) {
+      return (value) => {
+        this.$set(item, fieldName, value);
+      };
+    },
+    buildAttrs(item, field) {
+      return {
+        dense: true,
+        filled: true,
+        value: item[field.value],
+        inputValue: item[field.value],
+        label: field.text,
+      };
+    },
+    buildOn(setter) {
+      return {
+        input: setter,
+        change: setter,
+      };
+    },
+    handleSuccess() {
+      let snackbarTextKey = this.isCreate
+        ? this.createSuccessMessageI18nKey
+        : this.editSuccessMessageI18nKey;
+
+      this.$root.snackbarItems.push({
+        id: crypto.randomUUID(),
+        timeout: 5000,
+        messageKey: snackbarTextKey,
+        color: "success",
+      });
+    },
+    updateModel() {
+      // Only update the model if the dialog is hidden or has just been mounted
+      if (this.forceModelItemUpdate || !this.firstInitDone || !this.dialog) {
+        this.itemModel = JSON.parse(
+          JSON.stringify(this.isCreate ? this.defaultItem : this.editItem)
+        );
+      }
+    },
+  },
+  mounted() {
+    this.updateModel();
+    this.firstInitDone = true;
+
+    this.$watch("isCreate", this.updateModel);
+    this.$watch("defaultItem", this.updateModel, { deep: true });
+    this.$watch("editItem", this.updateModel, { deep: true });
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..24561d9f5bbe55fb53d6504edb35df756bcb8947
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue
@@ -0,0 +1,78 @@
+<template>
+  <mobile-fullscreen-dialog v-bind="$attrs" v-on="$listeners">
+    <template #title>{{ $t("actions.filter") }}</template>
+
+    <template #content>
+      <form ref="form" @submit.prevent="save">
+        <slot :attrs="attrs" :on="on"></slot>
+      </form>
+    </template>
+
+    <template #actions>
+      <cancel-button
+        i18n-key="actions.clear_filters"
+        @click="clearFilters"
+      ></cancel-button>
+      <save-button
+        i18n-key="actions.filter"
+        icon-text="$filterEmpty"
+        @click="save"
+      ></save-button>
+    </template>
+  </mobile-fullscreen-dialog>
+</template>
+
+<script>
+import MobileFullscreenDialog from "./MobileFullscreenDialog.vue";
+import CancelButton from "../buttons/CancelButton.vue";
+import SaveButton from "../buttons/SaveButton.vue";
+
+export default {
+  name: "FilterDialog",
+  components: { SaveButton, CancelButton, MobileFullscreenDialog },
+  props: {
+    filters: {
+      type: Object,
+      required: true,
+    },
+  },
+  methods: {
+    save() {
+      // Drop values that are null, as we don't want to apply empty filter
+      for (const key in this.filters) {
+        if (key in this.filters && this.filters[key] === null) {
+          // eslint-disable-next-line vue/no-mutating-props
+          delete this.filters[key];
+        }
+      }
+
+      this.$emit("filters", this.filters);
+      this.$emit("input", false);
+    },
+    clearFilters() {
+      this.$refs.form.reset();
+      this.$emit("filters", {});
+      this.$emit("input", false);
+    },
+    on(field) {
+      return {
+        // eslint-disable-next-line vue/no-mutating-props
+        change: (i) => (this.filters[field] = i),
+        // eslint-disable-next-line vue/no-mutating-props
+        input: (i) => (this.filters[field] = i),
+      };
+    },
+    attrs(field, defaultValue) {
+      if ([null, undefined].includes(this.filters[field]) && !!defaultValue) {
+        // eslint-disable-next-line vue/no-mutating-props
+        this.filters[field] = defaultValue;
+      }
+      return {
+        value: this.filters[field],
+      };
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..91df04e68bfff958105a377d1049211497a81a5e
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue
@@ -0,0 +1,40 @@
+<template>
+  <v-dialog
+    v-bind="$attrs"
+    v-on="$listeners"
+    :fullscreen="$vuetify.breakpoint.xs"
+    :hide-overlay="$vuetify.breakpoint.xs"
+    max-width="600px"
+  >
+    <template #activator="activator">
+      <slot name="activator" v-bind="activator"></slot>
+    </template>
+    <template #default>
+      <slot>
+        <v-card class="d-flex flex-column">
+          <v-card-title>
+            <slot name="title"></slot>
+          </v-card-title>
+          <v-card-text>
+            <slot name="content"></slot>
+          </v-card-text>
+          <v-spacer />
+          <v-divider />
+          <v-card-actions>
+            <v-spacer></v-spacer>
+            <slot name="actions"></slot>
+          </v-card-actions>
+        </v-card>
+      </slot>
+    </template>
+  </v-dialog>
+</template>
+
+<script>
+export default {
+  name: "MobileFullscreenDialog",
+  extends: "v-dialog",
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/ColorField.vue b/aleksis/core/frontend/components/generic/forms/ColorField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6f11516c877fe4a2a9df213c751251e13ced397c
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/ColorField.vue
@@ -0,0 +1,61 @@
+<template>
+  <v-menu
+    ref="menu"
+    v-model="menu"
+    :close-on-content-click="false"
+    transition="scale-transition"
+    offset-y
+    min-width="auto"
+    eager
+  >
+    <template #activator="{ on, attrs }">
+      <v-text-field
+        v-model="color"
+        v-bind="$attrs"
+        v-on="$listeners"
+        placeholder="#AABBCC"
+        :rules="rules"
+      >
+        <template #prepend-inner>
+          <v-icon :color="color" v-bind="attrs" v-on="on"> mdi-circle </v-icon>
+        </template>
+      </v-text-field>
+    </template>
+    <v-color-picker v-if="menu" v-model="color" ref="picker"></v-color-picker>
+  </v-menu>
+</template>
+
+<script>
+export default {
+  name: "DateField",
+  extends: "v-text-field",
+  data() {
+    return {
+      menu: false,
+      rules: [
+        (value) =>
+          /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))?$/i.test(value) ||
+          this.$t("forms.errors.invalid_color"),
+      ],
+    };
+  },
+  props: {
+    value: {
+      type: String,
+      default: undefined,
+    },
+  },
+  computed: {
+    color: {
+      get() {
+        return this.value;
+      },
+      set(newValue) {
+        this.$emit("input", newValue);
+      },
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/DateField.vue b/aleksis/core/frontend/components/generic/forms/DateField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..68a8772fad15172b319711bb7ce9a4a6f5c79f7b
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/DateField.vue
@@ -0,0 +1,109 @@
+<template>
+  <v-menu
+    ref="menu"
+    v-model="menu"
+    :close-on-content-click="false"
+    transition="scale-transition"
+    offset-y
+    min-width="auto"
+    eager
+  >
+    <template #activator="{ on, attrs }">
+      <v-text-field
+        v-model="date"
+        v-bind="{ ...$attrs, ...attrs }"
+        @click="handleClick"
+        @focusin="handleFocusIn"
+        @focusout="handleFocusOut"
+        @click:clear="handleClickClear"
+        placeholder="YYYY-MM-DD"
+        @keydown.esc="menu = false"
+        @keydown.enter="menu = false"
+        :rules="rules"
+      ></v-text-field>
+    </template>
+    <v-date-picker
+      v-model="date"
+      ref="picker"
+      no-title
+      scrollable
+      :min="min"
+      :max="max"
+      :locale="$i18n.locale"
+      first-day-of-week="1"
+      show-adjacent-months
+      @input="menu = false"
+    ></v-date-picker>
+  </v-menu>
+</template>
+
+<script>
+export default {
+  name: "DateField",
+  extends: "v-text-field",
+  data() {
+    return {
+      menu: false,
+      innerDate: this.value,
+      openDueToFocus: true,
+      rules: [
+        (value) =>
+          !value || !!Date.parse(value) || this.$t("forms.errors.invalid_date"),
+      ],
+    };
+  },
+  props: {
+    value: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    min: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    max: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+  },
+  computed: {
+    date: {
+      get() {
+        return this.innerDate;
+      },
+      set(value) {
+        this.innerDate = value;
+        this.$emit("input", value);
+      },
+    },
+  },
+  methods: {
+    handleClickClear() {
+      if (this.clearable) {
+        this.date = null;
+      }
+    },
+    handleClick() {
+      this.menu = true;
+      this.openDueToFocus = false;
+    },
+    handleFocusIn() {
+      this.openDueToFocus = true;
+      this.menu = true;
+    },
+    handleFocusOut() {
+      if (this.openDueToFocus) this.menu = false;
+    },
+  },
+  watch: {
+    value(newValue) {
+      this.innerDate = newValue;
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..da046a69b36014968995db908d5190e5f027ca30
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue
@@ -0,0 +1,115 @@
+<script setup>
+import DateField from "./DateField.vue";
+import TimeField from "./TimeField.vue";
+</script>
+
+<template>
+  <v-row>
+    <v-col cols="7">
+      <date-field
+        v-model="date"
+        v-bind="{ ...$attrs }"
+        :label="$t('forms.date_time.date')"
+        :min="minDate"
+        :max="maxDate"
+      />
+    </v-col>
+    <v-col cols="5">
+      <time-field
+        v-model="time"
+        v-bind="{ ...$attrs }"
+        :label="$t('forms.date_time.time')"
+        :min="minTime"
+        :max="maxTime"
+      />
+    </v-col>
+  </v-row>
+</template>
+
+<script>
+export default {
+  name: "DateTimeField",
+  data() {
+    return {
+      innerDateTime: this.value,
+    };
+  },
+  props: {
+    value: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    minDate: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    maxDate: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    minTime: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    maxTime: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+  },
+  computed: {
+    dateTime: {
+      get() {
+        return new Date(this.innerDateTime);
+      },
+      set(value) {
+        this.innerDateTime = value;
+        this.$emit("input", value);
+      },
+    },
+    date: {
+      get() {
+        return this.dateTime.toISOString().split("T")[0];
+      },
+      set(value) {
+        let newDateTime = this.dateTime;
+        const [year, month, day] = value.split("-");
+
+        newDateTime.setFullYear(year);
+        newDateTime.setMonth(month - 1);
+        newDateTime.setDate(day);
+
+        this.dateTime = newDateTime.toISOString();
+      },
+    },
+    time: {
+      get() {
+        return `${("0" + this.dateTime.getHours()).slice(-2)}:${(
+          "0" + this.dateTime.getMinutes()
+        ).slice(-2)}`;
+      },
+      set(value) {
+        let newDateTime = this.dateTime;
+
+        const [hours, minutes] = value.split(":");
+
+        newDateTime.setHours(hours);
+        newDateTime.setMinutes(minutes);
+
+        this.dateTime = newDateTime.toISOString();
+      },
+    },
+  },
+  watch: {
+    value(newValue) {
+      this.innerDateTime = newValue;
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b32859065e17cda75c2697b577ad3b9bf09d3aba
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue
@@ -0,0 +1,183 @@
+<template>
+  <v-autocomplete
+    v-bind="$attrs"
+    v-on="$listeners"
+    :items="items"
+    item-value="id"
+    :item-text="itemName"
+    class="fc-my-auto"
+  >
+    <template #append-outer>
+      <v-btn icon @click="menu = true">
+        <v-icon>$plus</v-icon>
+      </v-btn>
+
+      <slot
+        name="createComponent"
+        :attrs="{
+          value: menu,
+          defaultItem: defaultItem,
+          gqlQuery: gqlQuery,
+          gqlCreateMutation: gqlCreateMutation,
+          gqlPatchMutation: gqlPatchMutation,
+          isCreate: true,
+          fields: fields,
+          getCreateData: getCreateData,
+          createItemI18nKey: createItemI18nKey,
+        }"
+        :on="{
+          input: (i) => (menu = i),
+          cancel: () => (menu = false),
+          save: handleSave,
+          update: handleUpdate,
+        }"
+      >
+        <dialog-object-form
+          v-model="menu"
+          @cancel="menu = false"
+          @update="handleUpdate"
+          @save="handleSave"
+          @error="handleError"
+          :is-create="true"
+          :default-item="defaultItem"
+          :fields="fields"
+          :gql-query="gqlQuery"
+          :gql-patch-mutation="gqlPatchMutation"
+          :gql-create-mutation="gqlCreateMutation"
+          :create-item-i18n-key="createItemI18nKey"
+          :get-create-data="getCreateData"
+        >
+          <template
+            v-for="(_, name) in $scopedSlots"
+            :slot="name"
+            slot-scope="slotData"
+          >
+            <slot :name="name" v-bind="slotData" />
+          </template>
+        </dialog-object-form>
+      </slot>
+
+      <closable-snackbar :color="snackbarState" v-model="snackbar">
+        {{ snackbarText }}
+      </closable-snackbar>
+    </template>
+  </v-autocomplete>
+</template>
+
+<script>
+import ClosableSnackbar from "../dialogs/ClosableSnackbar.vue";
+import DialogObjectForm from "../dialogs/DialogObjectForm.vue";
+
+export default {
+  name: "ForeignKeyField",
+  components: { ClosableSnackbar, DialogObjectForm },
+  extends: "v-autocomplete",
+  data() {
+    return {
+      menu: false,
+      snackbar: false,
+      snackbarState: "error",
+      snackbarText: "",
+    };
+  },
+  apollo: {
+    items() {
+      return {
+        query: this.gqlQuery,
+        fetchPolicy: "cache-first",
+      };
+    },
+  },
+  methods: {
+    handleUpdate(store, createdObject) {
+      // Read the data from cache for query
+      const storedData = store.readQuery({ query: this.gqlQuery });
+
+      if (!storedData) {
+        // There are no data in the cache yet
+        return;
+      }
+
+      const storedDataKey = Object.keys(storedData)[0];
+
+      // Add item to stored data
+      storedData[storedDataKey].push(createdObject);
+
+      // Write data back to the cache
+      store.writeQuery({ query: this.gqlQuery, data: storedData });
+    },
+    handleSave(data) {
+      let newItem =
+        data.data[this.gqlCreateMutation.definitions[0].name.value].item;
+      let newValue = this.$attrs["return-object"] ? newItem : newItem.id;
+      let modelValue =
+        "multiple" in this.$attrs
+          ? Array.isArray(this.$attrs.value)
+            ? this.$attrs.value.concat(newValue)
+            : [newValue]
+          : newValue;
+
+      this.$emit("input", modelValue);
+    },
+    slotName(field) {
+      return field.value + ".field";
+    },
+    handleError(error) {
+      console.error(error);
+      if (error instanceof String) {
+        // error is a translation key or simply a string
+        this.snackbarText = this.$t(error);
+      } else if (error instanceof Object && error.message) {
+        this.snackbarText = error.message;
+      } else {
+        this.snackbarText = this.$t("graphql.snackbar_error_message");
+      }
+      this.snackbarState = "error";
+      this.snackbar = true;
+    },
+  },
+  props: {
+    defaultItem: {
+      type: Object,
+      required: true,
+    },
+    fields: {
+      type: Array,
+      required: true,
+    },
+    gqlQuery: {
+      type: Object,
+      required: true,
+    },
+    gqlCreateMutation: {
+      type: Object,
+      required: true,
+    },
+    gqlPatchMutation: {
+      type: Object,
+      required: true,
+    },
+    getCreateData: {
+      type: Function,
+      required: false,
+      default: (item) => item,
+    },
+    itemName: {
+      type: String,
+      required: false,
+      default: "name",
+    },
+    createItemI18nKey: {
+      type: String,
+      required: false,
+      default: "actions.create",
+    },
+  },
+};
+</script>
+
+<style scoped>
+.fc-my-auto > :first-child {
+  margin-block: auto;
+}
+</style>
diff --git a/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5deaab572366481ee22ff147028bfb32a79e00eb
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue
@@ -0,0 +1,56 @@
+<template>
+  <v-text-field
+    v-bind="$attrs"
+    v-on="on"
+    :rules="rules"
+    type="number"
+    inputmode="decimal"
+  ></v-text-field>
+</template>
+
+<script>
+export default {
+  name: "PositiveSmallIntegerField",
+  extends: "v-text-field",
+  methods: {
+    handleInput(event) {
+      let num = parseInt(event);
+      if (!isNaN(num) && num >= 0 && num <= 32767 && num % 1 === 0) {
+        this.$emit("input", parseInt(event));
+      }
+    },
+  },
+  data() {
+    return {
+      rules: [
+        (value) =>
+          !value ||
+          !isNaN(parseInt(value)) ||
+          this.$t("forms.errors.not_a_number"),
+        (value) =>
+          !value ||
+          value % 1 === 0 ||
+          this.$t("forms.errors.not_a_whole_number"),
+        (value) =>
+          !value ||
+          parseInt(value) >= 0 ||
+          this.$t("forms.errors.number_too_small"),
+        (value) =>
+          !value ||
+          parseInt(value) <= 32767 ||
+          this.$t("forms.errors.number_too_big"),
+      ],
+    };
+  },
+  computed: {
+    on() {
+      return {
+        ...this.$listeners,
+        input: this.handleInput,
+      };
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/TimeField.vue b/aleksis/core/frontend/components/generic/forms/TimeField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..bff97c880f8cf7cea92924ec49987ce9f78a22ef
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/TimeField.vue
@@ -0,0 +1,114 @@
+<template>
+  <v-menu
+    ref="menu"
+    v-model="menu"
+    :close-on-content-click="false"
+    transition="scale-transition"
+    offset-y
+    min-width="290"
+    eager
+  >
+    <template #activator="{ on, attrs }">
+      <v-text-field
+        v-model="time"
+        v-bind="{ ...$attrs, ...attrs }"
+        @click="handleClick"
+        @focusin="handleFocusIn"
+        @focusout="handleFocusOut"
+        @click:clear="handleClickClear"
+        placeholder="HH:MM[:SS]"
+        @keydown.esc="menu = false"
+        @keydown.enter="menu = false"
+        :prepend-icon="prependIcon"
+        :rules="rules"
+      ></v-text-field>
+    </template>
+    <v-time-picker
+      v-model="time"
+      ref="picker"
+      :min="min"
+      :max="max"
+      full-width
+      format="24hr"
+      @click:minute="menu = false"
+    ></v-time-picker>
+  </v-menu>
+</template>
+
+<script>
+export default {
+  name: "TimeField",
+  extends: "v-text-field",
+  data() {
+    return {
+      menu: false,
+      innerTime: this.value,
+      openDueToFocus: true,
+      rules: [
+        (v) =>
+          !v ||
+          /^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/.test(v) ||
+          this.$t("forms.errors.invalid_time"),
+      ],
+    };
+  },
+  props: {
+    value: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    min: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    max: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+    prependIcon: {
+      type: String,
+      required: false,
+      default: undefined,
+    },
+  },
+  computed: {
+    time: {
+      get() {
+        return this.innerTime;
+      },
+      set(value) {
+        this.innerTime = value;
+        this.$emit("input", value);
+      },
+    },
+  },
+  methods: {
+    handleClickClear() {
+      if (this.clearable) {
+        this.time = null;
+      }
+    },
+    handleClick() {
+      this.menu = true;
+      this.openDueToFocus = false;
+    },
+    handleFocusIn() {
+      this.openDueToFocus = true;
+      this.menu = true;
+    },
+    handleFocusOut() {
+      if (this.openDueToFocus) this.menu = false;
+    },
+  },
+  watch: {
+    value(newValue) {
+      this.innerTime = newValue;
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/generic/forms/WeekDayField.vue b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4e8c359343f799c43974ea923a27c8648bfa314c
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue
@@ -0,0 +1,68 @@
+<template>
+  <v-autocomplete
+    v-bind="$attrs"
+    v-on="$listeners"
+    :items="items"
+    :item-value="valueKey"
+  ></v-autocomplete>
+</template>
+
+<script>
+export default {
+  name: "WeekDayField",
+  extends: "v-autocomplete",
+  data() {
+    return {
+      items: [
+        {
+          value: "A_0",
+          valueInt: 0,
+          text: this.$t("weekdays.A_0"),
+        },
+        {
+          value: "A_1",
+          valueInt: 1,
+          text: this.$t("weekdays.A_1"),
+        },
+        {
+          value: "A_2",
+          valueInt: 2,
+          text: this.$t("weekdays.A_2"),
+        },
+        {
+          value: "A_3",
+          valueInt: 3,
+          text: this.$t("weekdays.A_3"),
+        },
+        {
+          value: "A_4",
+          valueInt: 4,
+          text: this.$t("weekdays.A_4"),
+        },
+        {
+          value: "A_5",
+          valueInt: 5,
+          text: this.$t("weekdays.A_5"),
+        },
+        {
+          value: "A_6",
+          valueInt: 6,
+          text: this.$t("weekdays.A_6"),
+        },
+      ],
+    };
+  },
+  props: {
+    returnInt: {
+      type: Boolean,
+      default: false,
+      required: false,
+    },
+  },
+  computed: {
+    valueKey() {
+      return this.returnInt ? "valueInt" : "value";
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/room/RoomInlineList.vue b/aleksis/core/frontend/components/room/RoomInlineList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5d65067ee94a6b0c8c9dce3964200c315bc583c7
--- /dev/null
+++ b/aleksis/core/frontend/components/room/RoomInlineList.vue
@@ -0,0 +1,70 @@
+<script setup>
+import InlineCRUDList from "../generic/InlineCRUDList.vue";
+</script>
+
+<template>
+  <inline-c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    create-item-i18n-key="rooms.create_room"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+    :default-item="defaultItem"
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #shortName.field="{ attrs, on, isCreate }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="shortNameRules"
+        ></v-text-field>
+      </div>
+    </template>
+  </inline-c-r-u-d-list>
+</template>
+
+<script>
+import {
+  rooms,
+  createRoom,
+  deleteRoom,
+  deleteRooms,
+  updateRooms,
+} from "./room.graphql";
+
+export default {
+  name: "RoomInlineList",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("rooms.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("rooms.short_name"),
+          value: "shortName",
+        },
+      ],
+      i18nKey: "rooms",
+      gqlQuery: rooms,
+      gqlCreateMutation: createRoom,
+      gqlPatchMutation: updateRooms,
+      gqlDeleteMutation: deleteRoom,
+      gqlDeleteMultipleMutation: deleteRooms,
+      defaultItem: {
+        name: "",
+        shortName: "",
+      },
+      shortNameRules: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/room/room.graphql b/aleksis/core/frontend/components/room/room.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..d8cd9138dd0fa5c4f3af7a7d47fe7b44ed94f6fd
--- /dev/null
+++ b/aleksis/core/frontend/components/room/room.graphql
@@ -0,0 +1,45 @@
+query rooms($orderBy: [String], $filters: JSONString) {
+  items: rooms(orderBy: $orderBy, filters: $filters) {
+    id
+    name
+    shortName
+    canEdit
+    canDelete
+  }
+}
+
+mutation createRoom($input: CreateRoomInput!) {
+  createRoom(input: $input) {
+    room {
+      id
+      name
+      shortName
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteRoom($id: ID!) {
+  deleteRoom(id: $id) {
+    ok
+  }
+}
+
+mutation deleteRooms($ids: [ID]!) {
+  deleteRooms(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateRooms($input: [BatchPatchRoomInput]!) {
+  batchMutation: updateRooms(input: $input) {
+    items: rooms {
+      id
+      name
+      shortName
+      canEdit
+      canDelete
+    }
+  }
+}
diff --git a/aleksis/core/frontend/components/school_term/SchoolTermField.vue b/aleksis/core/frontend/components/school_term/SchoolTermField.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5a2ba1a752a69b56aed7d26db24f073d776238f6
--- /dev/null
+++ b/aleksis/core/frontend/components/school_term/SchoolTermField.vue
@@ -0,0 +1,98 @@
+<script setup>
+import DateField from "../generic/forms/DateField.vue";
+</script>
+
+<template>
+  <foreign-key-field
+    :gql-patch-mutation="{}"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-query="gqlQuery"
+    :fields="fields"
+    create-item-i18n-key="school_term.create_school_term"
+    :default-item="defaultItem"
+    v-bind="$attrs"
+    v-on="$listeners"
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #name.field="{ attrs, on }">
+      <div aria-required="true">
+        <v-text-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="required"
+        ></v-text-field>
+      </div>
+    </template>
+
+    <template #dateStart="{ item }">
+      {{ $d(new Date(item.dateStart), "short") }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #dateStart.field="{ attrs, on, item }">
+      <div aria-required="true">
+        <date-field
+          v-bind="attrs"
+          v-on="on"
+          required
+          :rules="required"
+          :max="item ? item.dateEnd : undefined"
+        ></date-field>
+      </div>
+    </template>
+
+    <template #dateEnd="{ item }">
+      {{ $d(new Date(item.dateEnd), "short") }}
+    </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <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"
+        ></date-field>
+      </div>
+    </template>
+  </foreign-key-field>
+</template>
+
+<script>
+import ForeignKeyField from "../generic/forms/ForeignKeyField.vue";
+import { createSchoolTerm, schoolTerms } from "./schoolTerm.graphql";
+
+export default {
+  name: "SchoolTermField",
+  components: { ForeignKeyField },
+  data() {
+    return {
+      gqlQuery: schoolTerms,
+      gqlCreateMutation: createSchoolTerm,
+      fields: [
+        {
+          text: this.$t("school_term.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("school_term.date_start"),
+          value: "dateStart",
+        },
+        {
+          text: this.$t("school_term.date_end"),
+          value: "dateEnd",
+        },
+      ],
+      defaultItem: {
+        name: "",
+        dateStart: "",
+        dateEnd: "",
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b18c13f1a88196b9769262e3a5f3347a29d9ead4
--- /dev/null
+++ b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue
@@ -0,0 +1,117 @@
+<script setup>
+import InlineCRUDList from "../generic/InlineCRUDList.vue";
+import DateField from "../generic/forms/DateField.vue";
+</script>
+
+<template>
+  <inline-c-r-u-d-list
+    :headers="headers"
+    :i18n-key="i18nKey"
+    create-item-i18n-key="school_term.create_school_term"
+    :gql-query="gqlQuery"
+    :gql-create-mutation="gqlCreateMutation"
+    :gql-patch-mutation="gqlPatchMutation"
+    :gql-delete-mutation="gqlDeleteMutation"
+    :gql-delete-multiple-mutation="gqlDeleteMultipleMutation"
+    :default-item="defaultItem"
+    filter
+  >
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #name.field="{ attrs, on, item }">
+      <div aria-required="true">
+        <v-text-field v-bind="attrs" v-on="on" :rules="required"></v-text-field>
+      </div>
+    </template>
+
+    <template #dateStart="{ item }">{{
+      $d(new Date(item.dateStart), "short")
+    }}</template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #dateStart.field="{ attrs, on, item }">
+      <div aria-required="true">
+        <date-field
+          v-bind="attrs"
+          v-on="on"
+          :rules="required"
+          :max="item ? item.dateEnd : undefined"
+        ></date-field>
+      </div>
+    </template>
+
+    <template #dateEnd="{ item }">{{
+      $d(new Date(item.dateEnd), "short")
+    }}</template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <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"
+        ></date-field>
+      </div>
+    </template>
+
+    <template #filters="{ attrs, on }">
+      <date-field
+        v-bind="attrs('date_end__gte')"
+        v-on="on('date_end__gte')"
+        :label="$t('school_term.after')"
+      />
+
+      <date-field
+        v-bind="attrs('date_start__lte')"
+        v-on="on('date_start__lte')"
+        :label="$t('school_term.before')"
+      />
+    </template>
+  </inline-c-r-u-d-list>
+</template>
+
+<script>
+import {
+  schoolTerms,
+  createSchoolTerm,
+  deleteSchoolTerm,
+  deleteSchoolTerms,
+  updateSchoolTerms,
+} from "./schoolTerm.graphql";
+
+export default {
+  name: "SchoolTermInlineList",
+  data() {
+    return {
+      headers: [
+        {
+          text: this.$t("school_term.name"),
+          value: "name",
+        },
+        {
+          text: this.$t("school_term.date_start"),
+          value: "dateStart",
+        },
+        {
+          text: this.$t("school_term.date_end"),
+          value: "dateEnd",
+        },
+      ],
+      i18nKey: "school_term",
+      gqlQuery: schoolTerms,
+      gqlCreateMutation: createSchoolTerm,
+      gqlPatchMutation: updateSchoolTerms,
+      gqlDeleteMutation: deleteSchoolTerm,
+      gqlDeleteMultipleMutation: deleteSchoolTerms,
+      defaultItem: {
+        name: "",
+        dateStart: "",
+        dateEnd: "",
+      },
+      required: [(value) => !!value || this.$t("forms.errors.required")],
+    };
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/frontend/components/school_term/schoolTerm.graphql b/aleksis/core/frontend/components/school_term/schoolTerm.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..bfd681b2eee97d781e4d489a9c82cdfc95d9bf5f
--- /dev/null
+++ b/aleksis/core/frontend/components/school_term/schoolTerm.graphql
@@ -0,0 +1,48 @@
+query schoolTerms($orderBy: [String], $filters: JSONString) {
+  items: schoolTerms(orderBy: $orderBy, filters: $filters) {
+    id
+    name
+    dateStart
+    dateEnd
+    canEdit
+    canDelete
+  }
+}
+
+mutation createSchoolTerm($input: CreateSchoolTermInput!) {
+  createSchoolTerm(input: $input) {
+    schoolTerm {
+      id
+      name
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
+    }
+  }
+}
+
+mutation deleteSchoolTerm($id: ID!) {
+  deleteSchoolTerm(id: $id) {
+    ok
+  }
+}
+
+mutation deleteSchoolTerms($ids: [ID]!) {
+  deleteSchoolTerms(ids: $ids) {
+    deletionCount
+  }
+}
+
+mutation updateSchoolTerms($input: [BatchPatchSchoolTermInput]!) {
+  batchMutation: updateSchoolTerms(input: $input) {
+    items: schoolTerms {
+      id
+      name
+      dateStart
+      dateEnd
+      canEdit
+      canDelete
+    }
+  }
+}
diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json
index 3fb6c19b89ea2dcdfd80a00c88496c4fabb89271..207db33feb3d4b49edb79ebae96230df241fa8fd 100644
--- a/aleksis/core/frontend/messages/de.json
+++ b/aleksis/core/frontend/messages/de.json
@@ -80,7 +80,9 @@
     "edit": "Bearbeiten",
     "save": "Speichern",
     "search": "Suchen",
-    "stop_editing": "Bearbeiten beenden"
+    "stop_editing": "Bearbeiten beenden",
+    "filter": "Filter",
+    "clear_filters": "Filter zurücksetzen"
   },
   "administration": {
     "backend_admin": {
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 8be55bd566f50295291d61b392fdfc23a63ba2e8..ceda00c3994bccf4fc2c7b5e9cb83fe5ec2d00d9 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -72,15 +72,22 @@
     }
   },
   "actions": {
+    "title": "Actions",
+    "select_action": "Select Action",
     "back": "Back",
     "cancel": "Cancel",
     "close": "Close",
     "confirm_deletion": "Are you sure you want to delete this item?",
+    "confirm_deletion_multiple": "Are you sure you want to delete these items?",
     "delete": "Delete",
     "edit": "Edit",
     "save": "Save",
     "search": "Search",
-    "stop_editing": "Stop editing"
+    "stop_editing": "Stop editing",
+    "create": "Add",
+    "filter": "Filter",
+    "clear_filters": "Clear Filters",
+    "update": "Update"
   },
   "administration": {
     "backend_admin": {
@@ -133,9 +140,6 @@
     "notice": "If the download does not start automatically, please click the button below.",
     "title": "Downloading PDF file ..."
   },
-  "graphql": {
-    "snackbar_error_message": "There was an error retrieving the page data. Please try again."
-  },
   "group": {
     "additional_field": {
       "menu_title": "Additional Fields",
@@ -235,17 +239,66 @@
   "school_term": {
     "menu_title": "School Terms",
     "title": "School Term",
-    "title_plural": "School Terms"
+    "title_plural": "School Terms",
+    "create_school_term": "Create School Term",
+    "date_start": "Start Date",
+    "date_end": "End Date",
+    "name": "Name",
+    "before": "Starts before",
+    "after": "Ends after"
   },
   "service_worker": {
     "dismiss": "Dismiss",
     "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."
+  },
   "status": {
     "changes": "You have unsaved changes.",
     "error": "There has been an error while saving the latest changes.",
     "saved": "All changes are saved.",
-    "updating": "Changes are being synced."
+    "updating": "Changes are being synced.",
+    "object_create_success": "The object was created successfully.",
+    "object_edit_success": "The object was edited successfully.",
+    "object_delete_success": "The object was deleted successfully.",
+    "objects_delete_success": "The objects were deleted successfully."
+  },
+  "rooms": {
+    "menu_title": "Rooms",
+    "title_plural": "Rooms",
+    "name": "Name",
+    "short_name": "Short Name",
+    "create_room": "Create new room"
+  },
+  "forms": {
+    "errors": {
+      "required": "This field is required.",
+      "invalid_date": "This is not a valid date.",
+      "invalid_time": "This is not a valid time.",
+      "invalid_color": "This is not a valid color.",
+      "not_a_number": "Not a valid number",
+      "not_a_whole_number": "Please enter a whole number",
+      "number_too_small": "Please enter a bigger number.",
+      "number_too_big": "Please enter a smaller number."
+    },
+    "date_time": {
+      "date": "Date",
+      "time": "Time"
+    }
+  },
+  "weekdays": {
+    "A_0": "Monday",
+    "A_1": "Tuesday",
+    "A_2": "Wednesday",
+    "A_3": "Thursday",
+    "A_4": "Friday",
+    "A_5": "Saturday",
+    "A_6": "Sunday"
+  },
+  "selection": {
+    "num_items_selected": "No items selected | 1 item selected | {n} items selected"
   }
 }
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index 2af742809548f5f2f70e9c13a1b7091e6d8145c5..84b3612f33510940b97dd57fec5370e49c768d4c 100644
--- a/aleksis/core/frontend/routes.js
+++ b/aleksis/core/frontend/routes.js
@@ -288,14 +288,23 @@ const routes = [
           permission: "core.invite_rule",
         },
       },
+      {
+        path: "/rooms/",
+        component: () => import("./components/room/RoomInlineList.vue"),
+        name: "core.rooms",
+        meta: {
+          inMenu: true,
+          titleKey: "rooms.menu_title",
+          toolbarTitle: "rooms.menu_title",
+          icon: "mdi-floor-plan",
+          permission: "core.view_rooms_rule",
+        },
+      },
     ],
   },
   {
     path: "#",
-    component: () => import("./components/LegacyBaseTemplate.vue"),
-    props: {
-      byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-    },
+    component: () => import("./components/Parent.vue"),
     name: "core.administration",
     meta: {
       inMenu: true,
@@ -344,10 +353,8 @@ const routes = [
       },
       {
         path: "/school_terms/",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
+        component: () =>
+          import("./components/school_term/SchoolTermInlineList.vue"),
         name: "core.school_terms",
         meta: {
           inMenu: true,
@@ -356,22 +363,6 @@ const routes = [
           permission: "core.view_schoolterm_rule",
         },
       },
-      {
-        path: "/school_terms/create/",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.create_school_term",
-      },
-      {
-        path: "/school_terms/:pk(\\d+)/",
-        component: () => import("./components/LegacyBaseTemplate.vue"),
-        props: {
-          byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true,
-        },
-        name: "core.editSchoolTerm",
-      },
       {
         path: "/dashboard_widgets/",
         component: () => import("./components/LegacyBaseTemplate.vue"),
diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py
index 57370441b37a9db241a8a8a0bebbd8f8f00f5753..747f328ea82060b2b0148d9c928a498e018cefc9 100644
--- a/aleksis/core/management/commands/vite.py
+++ b/aleksis/core/management/commands/vite.py
@@ -1,6 +1,7 @@
 import os
 
 from django.conf import settings
+from django.core.management.base import CommandError
 
 from django_yarnpkg.management.base import BaseYarnCommand
 from django_yarnpkg.yarn import yarn_adapter
@@ -26,4 +27,6 @@ class Command(BaseYarnCommand):
             yarn_adapter.install(settings.YARN_INSTALLED_APPS)
 
         # Run Vite build
-        run_vite([options["command"]])
+        ret = run_vite([options["command"]])
+        if ret != 0:
+            raise CommandError("yarn command failed", returncode=ret)
diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py
index 7a549775f1730d57f83e6c3486644fd2c3bdda65..c79e756f509ea428359c95617e3e84d573985cf2 100644
--- a/aleksis/core/managers.py
+++ b/aleksis/core/managers.py
@@ -11,7 +11,23 @@ from django_cte import CTEManager, CTEQuerySet
 from polymorphic.managers import PolymorphicManager
 
 
-class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager):
+class AlekSISBaseManager(_CurrentSiteManager):
+    """Base manager for AlekSIS model customisation."""
+
+    def unmanaged(self) -> QuerySet:
+        """Get instances that are not managed by any particular app."""
+        return super().get_queryset().filter(managed_by_app_label="")
+
+    def managed_by_app(self, app_label: str) -> QuerySet:
+        """Get instances managed by a particular app."""
+        return super().get_queryset().filter(managed_by_app_label=app_label)
+
+    def get_queryset(self) -> QuerySet:
+        return self.unmanaged()
+
+
+# FIXME rename this and other classes after removing sites framework
+class CurrentSiteManagerWithoutMigrations(AlekSISBaseManager):
     """CurrentSiteManager for auto-generating managers just by query sets."""
 
     use_in_migrations = False
@@ -123,5 +139,5 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager):
         return super().get_queryset().filter(widget_id__in=dashboard_widget_pks)
 
 
-class PolymorphicCurrentSiteManager(_CurrentSiteManager, PolymorphicManager):
+class PolymorphicCurrentSiteManager(AlekSISBaseManager, PolymorphicManager):
     """Default manager for extensible, polymorphic models."""
diff --git a/aleksis/core/migrations/0001_initial.py b/aleksis/core/migrations/0001_initial.py
index aa7398e14a68e208ce75400c04177b752b1be8d4..d36293fa5ac4e11b8c2ad9080e9f90e9c9b1bd7d 100644
--- a/aleksis/core/migrations/0001_initial.py
+++ b/aleksis/core/migrations/0001_initial.py
@@ -5,7 +5,7 @@ import aleksis.core.util.core_helpers
 import datetime
 from django.conf import settings
 import django.contrib.postgres.fields.jsonb
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 import django.db.models.deletion
 import phonenumber_field.modelfields
@@ -47,7 +47,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Addtitional fields for groups',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -80,7 +80,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Custom menus',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -99,7 +99,7 @@ class Migration(migrations.Migration):
                 'permissions': (('assign_child_groups_to_groups', 'Can assign child groups to groups'),),
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -136,7 +136,7 @@ class Migration(migrations.Migration):
                 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_person_groups', 'Can view persons groups'), ('view_personal_details', 'Can view personal details')),
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -149,7 +149,7 @@ class Migration(migrations.Migration):
             },
             bases=('core.person',),
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -187,7 +187,7 @@ class Migration(migrations.Migration):
                 'abstract': False,
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -209,7 +209,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Notifications',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -226,7 +226,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Group types',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -295,7 +295,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Custom menu items',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -313,7 +313,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Announcement recipients',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.CreateModel(
@@ -332,7 +332,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Activities',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
     ]
diff --git a/aleksis/core/migrations/0007_dashboard_widget_order.py b/aleksis/core/migrations/0007_dashboard_widget_order.py
index 78c1c8bcb372a8fd9abfa6bd578e88c70e61d01f..1089112baad1430d0a12ae7c61fdf6f95f8d0164 100644
--- a/aleksis/core/migrations/0007_dashboard_widget_order.py
+++ b/aleksis/core/migrations/0007_dashboard_widget_order.py
@@ -1,6 +1,6 @@
 # Generated by Django 3.1.4 on 2020-12-21 13:38
 
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 import django.db.models.deletion
 import django.utils.timezone
diff --git a/aleksis/core/migrations/0008_data_check_result.py b/aleksis/core/migrations/0008_data_check_result.py
index df2d51119513f8033da981c7cd12c6f9a93425b9..79fda72f47d6115cd609ea3a66e819ba3aedf3bd 100644
--- a/aleksis/core/migrations/0008_data_check_result.py
+++ b/aleksis/core/migrations/0008_data_check_result.py
@@ -1,6 +1,6 @@
 # Generated by Django 3.1.3 on 2020-11-14 16:11
 
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 import django.db.models.deletion
 import django.utils.timezone
@@ -58,6 +58,6 @@ class Migration(migrations.Migration):
                 "verbose_name": "Data check result",
                 "verbose_name_plural": "Data check results",
             },
-            managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),],
+            managers=[("objects", aleksis.core.managers.AlekSISBaseManager()),],
         ),
     ]
diff --git a/aleksis/core/migrations/0013_pdf_file.py b/aleksis/core/migrations/0013_pdf_file.py
index 4ae06cffe58f3d90798c05e67b6ffd8843f14fef..2a2853eba761df25862b879d45f42416b586d847 100644
--- a/aleksis/core/migrations/0013_pdf_file.py
+++ b/aleksis/core/migrations/0013_pdf_file.py
@@ -1,7 +1,7 @@
 # Generated by Django 3.2 on 2021-04-10 18:58
 
 import aleksis.core.models
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 import django.db.models.deletion
 import django.utils.timezone
@@ -40,7 +40,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'PDF files',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
     ]
diff --git a/aleksis/core/migrations/0016_taskuserassignment.py b/aleksis/core/migrations/0016_taskuserassignment.py
index 59328cf6b702ddba0e37d121f5f5ce264c04d657..e413e3f0e6d965d2e56bb8f071786be56f0342e2 100644
--- a/aleksis/core/migrations/0016_taskuserassignment.py
+++ b/aleksis/core/migrations/0016_taskuserassignment.py
@@ -1,7 +1,7 @@
 # Generated by Django 3.2 on 2021-05-09 10:55
 
 from django.conf import settings
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 import django.db.models.deletion
 
@@ -30,7 +30,7 @@ class Migration(migrations.Migration):
                 'verbose_name_plural': 'Task user assignments',
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
     ]
diff --git a/aleksis/core/migrations/0047_add_room_model.py b/aleksis/core/migrations/0047_add_room_model.py
index 36464dd97e3757ca97d22c7c7957fbfa141f75b8..d0f5482601245e4fa04032a3ed948b180968b64f 100644
--- a/aleksis/core/migrations/0047_add_room_model.py
+++ b/aleksis/core/migrations/0047_add_room_model.py
@@ -1,7 +1,7 @@
 # Generated by Django 3.2.15 on 2022-11-20 14:20
 
 from django.apps import apps
-import django.contrib.sites.managers
+import aleksis.core.managers
 from django.db import migrations, models
 from django.db.utils import ProgrammingError
 import django.db.models.deletion
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
                 'permissions': (('view_room_timetable', 'Can view room timetable'),),
             },
             managers=[
-                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+                ('objects', aleksis.core.managers.AlekSISBaseManager()),
             ],
         ),
         migrations.AddConstraint(
diff --git a/aleksis/core/migrations/0050_managed_by_app_label.py b/aleksis/core/migrations/0050_managed_by_app_label.py
new file mode 100644
index 0000000000000000000000000000000000000000..0de24ef5fe734d99c9c606b8b7b76a4167348190
--- /dev/null
+++ b/aleksis/core/migrations/0050_managed_by_app_label.py
@@ -0,0 +1,184 @@
+# Generated by Django 4.1.9 on 2023-07-06 21:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0049_oauthapplication_post_logout_redirect_uris"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="activity",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="additionalfield",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="announcement",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="announcementrecipient",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="custommenu",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="custommenuitem",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="dashboardwidgetorder",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="datacheckresult",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="group",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="grouptype",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="notification",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="pdffile",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="person",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="persongroupthrough",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="room",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="schoolterm",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+        migrations.AddField(
+            model_name="taskuserassignment",
+            name="managed_by_app_label",
+            field=models.CharField(
+                blank=True,
+                editable=False,
+                max_length=255,
+                verbose_name="App label of app responsible for managing this instance",
+            ),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index e734b40a8f7cf55b86e1f496cfb8e8cfe0099cfb..b55a700db5390895c2c827a9ec13931bc55dbac9 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -27,13 +27,14 @@ from dynamic_preferences.types import FilePreference
 from guardian.admin import GuardedModelAdmin
 from guardian.core import ObjectPermissionChecker
 from jsonstore.fields import IntegerField, JSONFieldMixin
-from material.base import Layout, LayoutNode
+from material.base import Fieldset, Layout, LayoutNode
 from polymorphic.base import PolymorphicModelBase
 from polymorphic.managers import PolymorphicManager
 from polymorphic.models import PolymorphicModel
 from rules.contrib.admin import ObjectPermissionsModelAdmin
 
 from aleksis.core.managers import (
+    AlekSISBaseManager,
     CurrentSiteManagerWithoutMigrations,
     PolymorphicCurrentSiteManager,
     SchoolTermRelatedQuerySet,
@@ -132,8 +133,16 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
     site = models.ForeignKey(
         Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False, related_name="+"
     )
-    objects = CurrentSiteManager()
-    objects_all_sites = models.Manager()
+    objects = AlekSISBaseManager()
+    # FIXME this is now broken, remove sites framework
+    objects_all = models.Manager()
+
+    managed_by_app_label = models.CharField(
+        max_length=255,
+        verbose_name="App label of app responsible for managing this instance",
+        editable=False,
+        blank=True,
+    )
 
     extra_permissions = []
 
@@ -381,7 +390,7 @@ class ExtensiblePolymorphicModel(
     """Model class for extensible, polymorphic models."""
 
     objects = PolymorphicCurrentSiteManager()
-    objects_all_sites = PolymorphicManager()
+    objects_all = PolymorphicManager()
 
     class Meta:
         abstract = True
@@ -449,6 +458,20 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass):
         cls.base_layout.append(node)
         cls.layout = Layout(*cls.base_layout)
 
+        visit_nodes = [node]
+        while visit_nodes:
+            current_node = visit_nodes.pop()
+            if isinstance(current_node, Fieldset):
+                visit_nodes += node.elements
+            else:
+                field_name = (
+                    current_node if isinstance(current_node, str) else current_node.field_name
+                )
+                field = fields_for_model(cls._meta.model, [field_name])[field_name]
+                cls._meta.fields.append(field_name)
+                cls.base_fields[field_name] = field
+                setattr(cls, field_name, field)
+
 
 class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin):
     """A base class for ModelAdmin combining django-guardian and rules."""
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index a11fbb427d3c585ec145c83446dbab9cc7b856e4..ae137be9da07fb643e17e00fd2111892802cd24b 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -12,25 +12,41 @@ from haystack.utils.loading import UnifiedIndex
 from ..models import (
     CustomMenu,
     DynamicRoute,
+    Group,
     Notification,
     OAuthAccessToken,
     PDFFile,
     Person,
+    Room,
     TaskUserAssignment,
 )
 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 .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .custom_menu import CustomMenuType
 from .dynamic_routes import DynamicRouteType
-from .group import GroupType  # noqa
+from .group import GroupType
 from .installed_apps import AppType
 from .message import MessageType
 from .notification import MarkNotificationReadMutation, NotificationType
 from .oauth import OAuthAccessTokenType, OAuthRevokeTokenMutation
 from .pdf import PDFFileType
 from .person import PersonMutation, PersonType
-from .school_term import SchoolTermType  # noqa
+from .room import (
+    RoomBatchDeleteMutation,
+    RoomBatchPatchMutation,
+    RoomCreateMutation,
+    RoomDeleteMutation,
+    RoomType,
+)
+from .school_term import (
+    SchoolTermBatchDeleteMutation,
+    SchoolTermBatchPatchMutation,
+    SchoolTermCreateMutation,
+    SchoolTermDeleteMutation,
+    SchoolTermType,
+)
 from .search import SearchResultType
 from .system_properties import SystemPropertiesType
 from .two_factor import TwoFactorType
@@ -46,6 +62,8 @@ class Query(graphene.ObjectType):
     person_by_id = graphene.Field(PersonType, id=graphene.ID())
     person_by_id_or_me = graphene.Field(PersonType, id=graphene.ID())
 
+    groups = graphene.List(GroupType)
+
     who_am_i = graphene.Field(UserType)
 
     system_properties = graphene.Field(SystemPropertiesType)
@@ -70,6 +88,11 @@ class Query(graphene.ObjectType):
 
     oauth_access_tokens = graphene.List(OAuthAccessTokenType)
 
+    rooms = FilterOrderList(RoomType)
+    room_by_id = graphene.Field(RoomType, id=graphene.ID())
+
+    school_terms = FilterOrderList(SchoolTermType)
+
     def resolve_ping(root, info, payload) -> str:
         return payload
 
@@ -102,6 +125,10 @@ class Query(graphene.ObjectType):
             raise PermissionDenied()
         return person
 
+    @staticmethod
+    def resolve_groups(root, info, **kwargs):
+        return get_objects_for_user(info.context.user, "core.view_group", Group)
+
     def resolve_who_am_i(root, info, **kwargs):
         return info.context.user
 
@@ -172,6 +199,16 @@ class Query(graphene.ObjectType):
     def resolve_oauth_access_tokens(root, info, **kwargs):
         return OAuthAccessToken.objects.filter(user=info.context.user)
 
+    @staticmethod
+    def resolve_room_by_id(root, info, **kwargs):
+        pk = kwargs.get("id")
+        room_object = Room.objects.get(pk=pk)
+
+        if not info.context.user.has_perm("core.view_room_rule", room_object):
+            raise PermissionDenied
+
+        return room_object
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
@@ -182,6 +219,16 @@ class Mutation(graphene.ObjectType):
 
     revoke_oauth_token = OAuthRevokeTokenMutation.Field()
 
+    create_room = RoomCreateMutation.Field()
+    delete_room = RoomDeleteMutation.Field()
+    delete_rooms = RoomBatchDeleteMutation.Field()
+    update_rooms = RoomBatchPatchMutation.Field()
+
+    create_school_term = SchoolTermCreateMutation.Field()
+    delete_school_term = SchoolTermDeleteMutation.Field()
+    delete_school_terms = SchoolTermBatchDeleteMutation.Field()
+    update_school_terms = SchoolTermBatchPatchMutation.Field()
+
 
 def build_global_schema():
     """Build global GraphQL schema from all apps."""
diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py
index 36d4f0647bd25d56b58e14fc4b737a3893e20372..198217379e257e427aa1332ae7bce7734d139c18 100644
--- a/aleksis/core/schema/base.py
+++ b/aleksis/core/schema/base.py
@@ -1,8 +1,12 @@
+import json
+
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import PermissionDenied
 from django.db.models import Model
 
 import graphene
-from graphene_django import DjangoObjectType
+from django_filters.filterset import FilterSet, filterset_factory
+from graphene_django import DjangoListField, DjangoObjectType
 
 from ..util.core_helpers import queryset_rules_filter
 
@@ -47,3 +51,151 @@ class DeleteMutation(graphene.Mutation):
             return cls(ok=True)
         else:
             raise PermissionDenied()
+
+
+class PermissionsTypeMixin:
+    """Mixin for adding permissions to a Graphene type.
+
+    To configure the names for the permissions or to do
+    different permission checking, override the respective
+    methods `resolve_can_edit` and `resolve_can_delete`
+    """
+
+    can_edit = graphene.Boolean()
+    can_delete = graphene.Boolean()
+
+    @staticmethod
+    def resolve_can_edit(root: Model, info, **kwargs):
+        content_type = ContentType.objects.get_for_model(root)
+        perm = f"{content_type.app_label}.edit_{content_type.model}_rule"
+        return info.context.user.has_perm(perm, root)
+
+    @staticmethod
+    def resolve_can_delete(root: Model, info, **kwargs):
+        content_type = ContentType.objects.get_for_model(root)
+        perm = f"{content_type.app_label}.delete_{content_type.model}_rule"
+        return info.context.user.has_perm(perm, root)
+
+
+class PermissionBatchPatchMixin:
+    class Meta:
+        login_required = True
+
+    @classmethod
+    def check_permissions(cls, root, info, input):  # noqa
+        if info.context.user.has_perms(cls._meta.permissions, root):
+            return
+
+        raise PermissionDenied()
+
+
+class PermissionBatchDeleteMixin:
+    class Meta:
+        login_required = True
+
+    @classmethod
+    def check_permissions(cls, root, info, input):  # noqa
+        if info.context.user.has_perms(cls._meta.permissions, root):
+            return
+
+        raise PermissionDenied()
+
+
+class PermissionPatchMixin:
+    class Meta:
+        login_required = True
+
+    @classmethod
+    def check_permissions(cls, root, info, input, id, obj):  # noqa
+        if info.context.user.has_perms(cls._meta.permissions, root):
+            return
+
+        raise PermissionDenied()
+
+
+class DjangoFilterMixin:
+    """Filters a queryset with django filter."""
+
+    @classmethod
+    def get_filterset(cls):
+        meta = getattr(cls, "_meta", None)
+
+        if not meta:
+            raise NotImplementedError(f"{cls.__name__} must implement class Meta for filtering.")
+
+        if hasattr(meta, "filterset_class"):
+            filterset = getattr(meta, "filterset_class")
+            if filterset is not None:
+                return filterset
+
+        model: Model = getattr(meta, "model")
+        fields = getattr(meta, "filter_fields", None)
+
+        if not model:
+            raise NotImplementedError(f"{cls.__name__} must supply a model via the Meta class")
+
+        if not fields:
+            # Django filter doesn't allow to filter without explicit fields
+            raise NotImplementedError(
+                f"{cls.__name__}.Meta must contain filter_fields or a filterset_class"
+            )
+
+        fs = filterset_factory(model=model, fields=fields)
+
+        return fs
+
+    @classmethod
+    def filter(cls, filters, queryset):  # noqa
+        filterset_class = cls.get_filterset()
+        filterset: FilterSet = filterset_class(filters, queryset)
+        return filterset.qs
+
+
+class FilterOrderList(DjangoListField):
+    """Generic filterable Field for lists of django models.
+
+    After the models are filtered, they can be filtered again (e.g.
+    for permissions using the get_queryset method inside the
+    DjangoObjectType subclass.
+    """
+
+    def __init__(self, _type, *args, **kwargs):
+        kwargs.update(order_by=graphene.List(graphene.String))
+        kwargs.update(filters=graphene.JSONString())
+        super().__init__(_type, *args, **kwargs)
+
+    @staticmethod
+    def list_resolver(
+        django_object_type,
+        resolver,
+        default_manager,
+        root,
+        info,
+        order_by=None,
+        filters=None,
+        **args,
+    ):
+        qs = DjangoListField.list_resolver(
+            django_object_type, resolver, default_manager, root, info, **args
+        )
+
+        if filters is not None:
+            if isinstance(filters, str):
+                filters = json.loads(filters)
+
+            if isinstance(filters, dict) and len(filters.keys()) > 0:
+                for f_key, f_value in filters.items():
+                    if isinstance(f_value, list):
+                        filters[f_key] = ",".join(map(str, f_value))
+
+                qs = django_object_type.filter(filters, qs)
+
+        if order_by is not None:
+            if isinstance(order_by, str):
+                order_by = [order_by]
+
+            qs = qs.order_by(*order_by)
+
+        print(f"{filters=}")
+
+        return qs
diff --git a/aleksis/core/schema/room.py b/aleksis/core/schema/room.py
index d4f76700793d2f7eededaac1b0361ebeff1a6a5a..575130971671cfa79743b985918b3ad1621da53f 100644
--- a/aleksis/core/schema/room.py
+++ b/aleksis/core/schema/room.py
@@ -1,9 +1,53 @@
 from graphene_django import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
 
 from ..models import Room
+from .base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
 
 
-class RoomType(DjangoObjectType):
+class RoomType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
     class Meta:
         model = Room
         fields = ("id", "name", "short_name")
+        filter_fields = {
+            "id": ["exact", "lte", "gte"],
+            "name": ["icontains"],
+            "short_name": ["icontains"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info):
+        return queryset  # FIXME filter this queryset based on permissions
+
+
+class RoomCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = Room
+        permissions = ("core.create_room",)
+
+
+class RoomDeleteMutation(DeleteMutation):
+    klass = Room
+    permission_required = "core.delete_room"
+
+
+class RoomBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = Room
+        permissions = ("core.delete_room",)
+
+
+class RoomBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
+    class Meta:
+        model = Room
+        permissions = ("core.change_room",)
diff --git a/aleksis/core/schema/school_term.py b/aleksis/core/schema/school_term.py
index 1f6e504bc280689c9011a63da7d0af3b5ec53a06..798d1c62eb960d2c850a918345cbc03252a88820 100644
--- a/aleksis/core/schema/school_term.py
+++ b/aleksis/core/schema/school_term.py
@@ -1,8 +1,71 @@
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.utils.translation import gettext as _
+
 from graphene_django import DjangoObjectType
+from graphene_django_cud.mutations import (
+    DjangoBatchDeleteMutation,
+    DjangoBatchPatchMutation,
+    DjangoCreateMutation,
+)
 
 from ..models import SchoolTerm
+from .base import (
+    DeleteMutation,
+    DjangoFilterMixin,
+    PermissionBatchDeleteMixin,
+    PermissionBatchPatchMixin,
+    PermissionsTypeMixin,
+)
+
+
+class SchoolTermType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType):
+    class Meta:
+        model = SchoolTerm
+        filter_fields = {
+            "name": ["icontains", "exact"],
+            "date_start": ["exact", "lt", "lte", "gt", "gte"],
+            "date_end": ["exact", "lt", "lte", "gt", "gte"],
+        }
+
+    @classmethod
+    def get_queryset(cls, queryset, info, **kwargs):
+        if not info.context.user.has_perm("view_schoolterm_rule"):
+            raise PermissionDenied
+
+        return queryset  # FIXME filter this queryset based on permissions
+
+
+class SchoolTermCreateMutation(DjangoCreateMutation):
+    class Meta:
+        model = SchoolTerm
+        permissions = ("core.create_school_term",)  # FIXME
+
+    @classmethod
+    def validate(cls, root, info, input):  # noqa
+        date_start = input.get("date_start")
+        date_end = input.get("date_end")
+        if date_end < date_start:
+            raise ValidationError(_("The start date must be earlier than the end date."))
+
+        qs = SchoolTerm.objects.within_dates(date_start, date_end)
+        if qs.exists():
+            raise ValidationError(
+                _("There is already a school term for this time or a part of this time.")
+            )
+
+
+class SchoolTermDeleteMutation(DeleteMutation):
+    klass = SchoolTerm
+    permission_required = "core.delete_school_term"  # FIXME
+
+
+class SchoolTermBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation):
+    class Meta:
+        model = SchoolTerm
+        permissions = ("core.delete_school_term",)
 
 
-class SchoolTermType(DjangoObjectType):
+class SchoolTermBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation):
     class Meta:
         model = SchoolTerm
+        permissions = ("core.change_school_term",)  # FIXME
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 656fcd30a69e245c02d7bf2a984517265c512da9..77cda4ee58ed24e8f3df6bab2196643023b3b5f7 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -935,12 +935,19 @@ LOGGING["root"] = {
     "handlers": ["console"],
     "level": _settings.get("logging.level", "WARNING"),
 }
+# Configure global log Format
+LOGGING["formatters"]["verbose"] = {
+    "format": "{asctime} {levelname} {name}[{process}]: {msg}",
+    "style": "{",
+}
 # Add null handler for selective silencing
 LOGGING["handlers"]["null"] = {"class": "logging.NullHandler"}
 # Make console logging independent of DEBUG
 LOGGING["handlers"]["console"]["filters"].remove("require_debug_true")
 # Use root log level for console
 del LOGGING["handlers"]["console"]["level"]
+# Use verbose log format for console
+LOGGING["handlers"]["console"]["formatter"] = "verbose"
 # Disable exception mails if not desired
 if not _settings.get("logging.mail_admins", True):
     LOGGING["loggers"]["django"]["handlers"].remove("mail_admins")
@@ -956,6 +963,9 @@ LOGGING["loggers"]["celery"] = {
     "level": _settings.get("logging.level", "WARNING"),
     "propagate": False,
 }
+# Set Django log levels
+LOGGING["loggers"]["django"]["level"] = _settings.get("logging.level", "WARNING")
+LOGGING["loggers"]["django.server"]["level"] = _settings.get("logging.level", "WARNING")
 
 # Rules and permissions
 
@@ -1040,6 +1050,9 @@ else:
     DEFAULT_FILE_STORAGE = "titofisto.TitofistoStorage"
     TITOFISTO_TIMEOUT = 10 * 60
 
+TITOFISTO_ENABLE_UPLOAD = True
+TITOFISTO_UPLOAD_NAMESPACE = "__titofisto__/upload/"
+
 SASS_PROCESSOR_STORAGE = DEFAULT_FILE_STORAGE
 
 SENTRY_ENABLED = _settings.get("health.sentry.enabled", False)
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index 5411cad4747bc8c5de011fcdd2abcde432842236..8963981bb22412b51abca69120b90dc3b0e18d72 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -10,24 +10,6 @@ from .models import Person
 from .util.core_helpers import get_site_preferences
 
 
-class SchoolTermTable(tables.Table):
-    """Table to list persons."""
-
-    class Meta:
-        attrs = {"class": "highlight"}
-
-    name = tables.LinkColumn("edit_school_term", args=[A("id")])
-    date_start = tables.Column()
-    date_end = tables.Column()
-    edit = tables.LinkColumn(
-        "edit_school_term",
-        args=[A("id")],
-        text=_("Edit"),
-        attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}},
-        verbose_name=_("Actions"),
-    )
-
-
 class PersonsTable(tables.Table):
     """Table to list persons."""
 
diff --git a/aleksis/core/templates/core/school_term/create.html b/aleksis/core/templates/core/school_term/create.html
deleted file mode 100644
index a3e049112caeaf84095dde68c7fd8d7a32f75602..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/core/school_term/create.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{# -*- engine:django -*- #}
-
-{% extends "core/base.html" %}
-{% load material_form i18n %}
-
-{% block browser_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %}
-{% block page_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %}
-
-{% block content %}
-
-  <form method="post">
-    {% csrf_token %}
-    {% form form=form %}{% endform %}
-    {% include "core/partials/save_button.html" %}
-  </form>
-
-{% endblock %}
diff --git a/aleksis/core/templates/core/school_term/edit.html b/aleksis/core/templates/core/school_term/edit.html
deleted file mode 100644
index aa1b1dcf5015e876d0b9aa316d25da31673f0a3f..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/core/school_term/edit.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{# -*- engine:django -*- #}
-
-{% extends "core/base.html" %}
-{% load material_form i18n %}
-
-{% block browser_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %}
-{% block page_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %}
-
-{% block content %}
-
-  <form method="post">
-    {% csrf_token %}
-    {% form form=form %}{% endform %}
-    {% include "core/partials/save_button.html" %}
-  </form>
-
-{% endblock %}
diff --git a/aleksis/core/templates/core/school_term/list.html b/aleksis/core/templates/core/school_term/list.html
deleted file mode 100644
index 9df6af9727b868e13d43a75c42730b375ff6aa47..0000000000000000000000000000000000000000
--- a/aleksis/core/templates/core/school_term/list.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{# -*- engine:django -*- #}
-
-{% extends "core/base.html" %}
-
-{% load i18n %}
-{% load render_table from django_tables2 %}
-
-{% block browser_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %}
-{% block page_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %}
-
-{% block content %}
-  <a class="btn green waves-effect waves-light" href="{% url 'create_school_term' %}">
-    <i class="material-icons left iconify" data-icon="mdi:add"></i>
-    {% trans "Create school term" %}
-  </a>
-
-  {% render_table table %}
-{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 7a22c7bb20a9d3c2d0d0e5af6bece161ac5dbdfa..a79562f9b19a311abc6eac48760b18169885d1b6 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -96,17 +96,6 @@ urlpatterns = [
                     views.TwoFactorSetupView.as_view(),
                     name="setup_two_factor_auth",
                 ),
-                path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"),
-                path(
-                    "school_terms/create/",
-                    views.SchoolTermCreateView.as_view(),
-                    name="create_school_term",
-                ),
-                path(
-                    "school_terms/<int:pk>/",
-                    views.SchoolTermEditView.as_view(),
-                    name="edit_school_term",
-                ),
                 path("persons/", views.persons, name="persons"),
                 path(
                     "person/", TemplateView.as_view(template_name="core/empty.html"), name="person"
diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py
index e370d6b125242381ab53f6c030c62d41c622d0ff..26f0752dc352cf0793ad7a9c7d70f95e015dd457 100644
--- a/aleksis/core/util/apps.py
+++ b/aleksis/core/util/apps.py
@@ -1,3 +1,4 @@
+import logging
 from importlib import metadata
 from typing import TYPE_CHECKING, Any, Optional, Sequence
 
@@ -26,8 +27,11 @@ class AppConfig(django.apps.AppConfig):
     def __init_subclass__(cls):
         super().__init_subclass__()
         cls.default = True
+        cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
 
     def ready(self):
+        self._logger.debug("Running app.ready")
+
         super().ready()
 
         # Register default listeners
@@ -36,9 +40,12 @@ class AppConfig(django.apps.AppConfig):
         preference_updated.connect(self.preference_updated)
         user_logged_in.connect(self.user_logged_in)
         user_logged_out.connect(self.user_logged_out)
+        self._logger.debug("Default signal handlers connected")
 
         # Getting an app ready means it should look at its config once
+        self._logger.debug("Force-loading preferences")
         self.preference_updated(self)
+        self._logger.debug("Preferences loaded")
 
     def get_distribution_name(self):
         """Get distribution name of application package."""
@@ -282,6 +289,8 @@ class AppConfig(django.apps.AppConfig):
         return {}
 
     def _maintain_default_data(self):
+        self._logger.debug("Maintaining default data for %s", self.get_name())
+
         from django.contrib.auth.models import Permission
         from django.contrib.contenttypes.models import ContentType
 
@@ -292,10 +301,19 @@ class AppConfig(django.apps.AppConfig):
         for model in self.get_models():
             if hasattr(model, "maintain_default_data"):
                 # Method implemented by each model object; can be left out
+                self._logger.info(
+                    "Maintaining default data of %s in %s", model._meta.model_name, self.get_name()
+                )
                 model.maintain_default_data()
             if hasattr(model, "extra_permissions"):
+                self._logger.info(
+                    "Maintaining extra permissions for %s in %s",
+                    model._meta.model_name,
+                    self.get_name(),
+                )
                 ct = ContentType.objects.get_for_model(model)
                 for perm, verbose_name in model.extra_permissions:
+                    self._logger.debug("Creating %s (%s)", perm, verbose_name)
                     Permission.objects.get_or_create(
                         codename=perm,
                         content_type=ct,
diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py
index 5d343acc0533a2509077c0f0d15ca0bc8d1823f9..885ac39fd51833c55ce072cc8a425991cfea7db2 100644
--- a/aleksis/core/util/frontend_helpers.py
+++ b/aleksis/core/util/frontend_helpers.py
@@ -49,7 +49,7 @@ def write_vite_values(out_path: str) -> dict[str, Any]:
         json.dump(vite_values, out)
 
 
-def run_vite(args: Optional[Sequence[str]] = None) -> None:
+def run_vite(args: Optional[Sequence[str]] = None) -> int:
     args = list(args) if args else []
 
     config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js")
@@ -64,7 +64,7 @@ def run_vite(args: Optional[Sequence[str]] = None) -> None:
     log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent")
     args += ["-l", log_level]
 
-    yarn_adapter.call_yarn(["run", "vite"] + args)
+    return yarn_adapter.call_yarn(["run", "vite"] + args)
 
 
 def get_language_cookie(code: str) -> str:
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index c1de17adafc9bc33205a364b619b4c23c5b1e897..442a3d4d9e86f1ecba128e87f556f959dbfe06fb 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -93,7 +93,6 @@ from .forms import (
     OAuthApplicationForm,
     PersonForm,
     PersonPreferenceForm,
-    SchoolTermForm,
     SelectPermissionForm,
     SitePreferenceForm,
 )
@@ -109,7 +108,6 @@ from .models import (
     OAuthApplication,
     Person,
     PersonInvitation,
-    SchoolTerm,
 )
 from .registries import (
     group_preferences_registry,
@@ -126,7 +124,6 @@ from .tables import (
     GroupTypesTable,
     InvitationsTable,
     PersonsTable,
-    SchoolTermTable,
     UserGlobalPermissionTable,
     UserObjectPermissionTable,
 )
@@ -263,40 +260,6 @@ def index(request: HttpRequest) -> HttpResponse:
     return render(request, "core/index.html", context)
 
 
-@method_decorator(pwa_cache, name="dispatch")
-class SchoolTermListView(PermissionRequiredMixin, SingleTableView):
-    """Table of all school terms."""
-
-    model = SchoolTerm
-    table_class = SchoolTermTable
-    permission_required = "core.view_schoolterm_rule"
-    template_name = "core/school_term/list.html"
-
-
-@method_decorator(never_cache, name="dispatch")
-class SchoolTermCreateView(PermissionRequiredMixin, AdvancedCreateView):
-    """Create view for school terms."""
-
-    model = SchoolTerm
-    form_class = SchoolTermForm
-    permission_required = "core.add_schoolterm_rule"
-    template_name = "core/school_term/create.html"
-    success_url = reverse_lazy("school_terms")
-    success_message = _("The school term has been created.")
-
-
-@method_decorator(never_cache, name="dispatch")
-class SchoolTermEditView(PermissionRequiredMixin, AdvancedEditView):
-    """Edit view for school terms."""
-
-    model = SchoolTerm
-    form_class = SchoolTermForm
-    permission_required = "core.edit_schoolterm"
-    template_name = "core/school_term/edit.html"
-    success_url = reverse_lazy("school_terms")
-    success_message = _("The school term has been saved.")
-
-
 @pwa_cache
 @permission_required("core.view_persons_rule")
 def persons(request: HttpRequest) -> HttpResponse:
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index 287534f096c186bc428de742c4e5ff56b71e8eab..415d12aa00f6b8a2bf2747e47d5cc3e32fd7aaa3 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -201,7 +201,9 @@ export default defineConfig({
         navigateFallback: "/",
         directoryIndex: null,
         navigateFallbackAllowlist: [
-          new RegExp("^/(?!(django|admin|graphql|__icons__))[^.]*$"),
+          new RegExp(
+            "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+          ),
         ],
         additionalManifestEntries: [
           { url: "/", revision: crypto.randomUUID() },
@@ -215,7 +217,7 @@ export default defineConfig({
         runtimeCaching: [
           {
             urlPattern: new RegExp(
-              "^/(?!(django|admin|graphql|__icons__))[^.]*$"
+              "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
             ),
             handler: "CacheFirst",
           },
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index 03ffc97b1e73b28561f2283338773672e4c41a73..b829cd2c92710efa6050f068319ef052e9b4138e 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -147,7 +147,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`:
 .. code-block:: shell
    pip3 install --break-system-packages aleksis
    aleksis-admin vite build
-   aleksis-admin collectstatic
+   aleksis-admin collectstatic --clear
    aleksis-admin migrate
    aleksis-admin createinitialrevisions
 
diff --git a/docs/conf.py b/docs/conf.py
index 919c2ec87fab03d7e7e6c7c982d695f76d559c43..4f14591b931a77ed87e0e2cc7af56b48805e820b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -29,9 +29,9 @@ copyright = "2019-2023 The AlekSIS team"
 author = "The AlekSIS Team"
 
 # The short X.Y version
-version = "3.1"
+version = "4.0"
 # The full version, including alpha/beta/rc tags
-release = "3.1.2.dev0"
+release = "4.0.0.dev0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst
index f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf..c98eb81925a370ad1e40ee8f000a70a8330892cc 100644
--- a/docs/dev/01_setup.rst
+++ b/docs/dev/01_setup.rst
@@ -7,7 +7,8 @@ by reading its documentation.
 
 Poetry makes a lot of stuff very easy, especially managing a virtual
 environment that contains AlekSIS and everything you need to run the
-framework and selected apps.
+framework and selected apps. The minimum supported version of Poetry
+is 1.2.0.
 
 Also, `Yarn`_ is needed to resolve JavaScript dependencies.
 
@@ -91,7 +92,7 @@ All three steps can be done with the ``poetry shell`` command and
 
   ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell
    poetry run aleksis-admin vite build
-   poetry run aleksis-admin collectstatic
+   poetry run aleksis-admin collectstatic --clear
    poetry run aleksis-admin compilemessages
    poetry run aleksis-admin migrate
    poetry run aleksis-admin createinitialrevisions
diff --git a/pyproject.toml b/pyproject.toml
index 7280e24280a5b7e92cd94957965194ed491d1b1f..2a38377509bcb52ce50fc57917a19d209d863618 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-Core"
-version = "3.1.2.dev0"
+version = "4.0.0.dev0"
 packages = [
     { include = "aleksis" }
 ]
@@ -28,12 +28,9 @@ authors = [
     "Benedict Suska <benedict.suska@teckids.de>",
     "Lukas Weichelt <lukas.weichelt@teckids.de>"
 ]
-maintainers = [
-    "Jonathan Weth <dev@jonathanweth.de>",
-    "Dominik George <dominik.george@teckids.org>"
-]
+maintainers = ["Jonathan Weth <dev@jonathanweth.de>", "Dominik George <dominik.george@teckids.org>"]
 license = "EUPL-1.2-or-later"
-homepage = "https://aleksis.org/"
+homepage = "https://aleksis.org"
 repository = "https://edugit.org/AlekSIS/official/AlekSIS-Core"
 documentation = "https://aleksis.org/AlekSIS-Core/docs/html/"
 keywords = ["SIS", "education", "school", "digitisation", "school apps"]
@@ -49,11 +46,14 @@ classifiers = [
     "Typing :: Typed",
 ]
 
+[[tool.poetry.source]]
+name = "PyPI"
+priority = "primary"
+
 [[tool.poetry.source]]
 name = "gitlab"
 url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple"
-secondary = true
-
+priority = "supplemental"
 [tool.poetry.dependencies]
 python = "^3.9"
 Django = "^4.1"
@@ -113,10 +113,10 @@ ipython = "^8.0.0"
 django-oauth-toolkit = "^2.0.0"
 django-storages = {version = "^1.13.2", optional = true}
 boto3 = {version = "^1.26.142", optional = true}
-django-cleanup = "^7.0.0"
+django-cleanup = "^8.0.0"
 djangorestframework = "^3.12.4"
 Whoosh = "^2.7.4"
-django-titofisto = "^0.2.0"
+django-titofisto = "^1.0.0"
 haystack-redis = "^0.0.1"
 python-gnupg = "^0.5.0"
 sentry-sdk = {version = "^1.4.3", optional = true}
@@ -124,27 +124,65 @@ django-cte = "^1.1.5"
 pycountry = "^22.0.0"
 django-iconify = "^0.3"
 customidenticon = "^0.1.5"
-graphene-django = "^3.0.0"
+graphene-django = ">=3.0.0, <=3.1.2"
 selenium = "^4.4.3"
 django-vite = "^2.0.2"
+graphene-django-cud = "^0.10.0"
+uwsgi = "^2.0.21"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
 s3 = ["boto3", "django-storages"]
 sentry = ["sentry-sdk"]
 
-[tool.poetry.dev-dependencies]
-aleksis-builddeps = {version=">=2023.1.dev0", allow-prereleases=true}
-selenium = "<4.10.0"
-uwsgi = "^2.0"
-
 [tool.poetry.scripts]
 aleksis-admin = 'aleksis.core.__main__:aleksis_cmd'
 
+[tool.poetry.group.dev.dependencies]
+django-stubs = "^4.2"
+
+safety = "^2.3.5"
+
+flake8 = "^6.0.0"
+flake8-django = "^1.0.0"
+flake8-fixme = "^1.1.1"
+flake8-mypy = "^17.8.0"
+flake8-bandit = "^4.1.1"
+flake8-builtins = "^2.0.0"
+flake8-docstrings = "^1.5.0"
+flake8-rst-docstrings = "^0.3.0"
+
+black = ">=21.0"
+flake8-black = "^0.3.0"
+
+isort = "^5.0.0"
+flake8-isort = "^6.0.0"
+
+curlylint = "^0.13.0"
+
+[tool.poetry.group.test.dependencies]
+pytest = "^7.2"
+pytest-django = "^4.1"
+pytest-django-testing-postgresql = "^0.2"
+pytest-cov = "^4.0.0"
+pytest-sugar = "^0.9.2"
+selenium = "<4.10.0"
+freezegun = "^1.1.0"
+
+[tool.poetry.group.docs]
+optional = true
+
+[tool.poetry.group.docs.dependencies]
+sphinx = "^7.0"
+sphinxcontrib-django = "^2.3.0"
+sphinxcontrib-svg2pdfconverter = "^1.1.1"
+sphinx-autodoc-typehints = "^1.7"
+sphinx_material = "^0.0.35"
+
 [tool.black]
 line-length = 100
 exclude = "/migrations/"
 
 [build-system]
-requires = ["poetry>=1.0"]
-build-backend = "poetry.masonry.api"
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"