diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index a84ef32e46e3cddfecf361f065ec4c59d89c39cb..bd2d94859e32abf0c17f3b8dd568cd4def22ae72 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,12 +9,73 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+Changes
+=======
+The "managed models" feature is mandatory for all models derived from `ExtensibleModel`
+and requires creating a migration for all downstream models to add the respective
+field.
+
 Added
 ~~~~~
 
+* Frontend for managing rooms.
 * Introduce Holiday model to track information about holidays.
+* [Dev] Components for implementing standard CRUD operations in new frontend.
+* [Dev] Options for filtering and sorting of GraphQL queries at the server.
+* [Dev] Managed models for instances handled by other apps.
+* [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients
 
-Changes
+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.
+* Rendering of "simple" PDF templates failed when used with S3 storage.
+
+`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
+---------------------
+
+Fixed
+~~~~~
+
+* Progress page didn't work properly.
+* About page failed to load for apps with an unknown licence.
+* QUeries for persons with partial permissions failed.
+* Some pages couldn't be scrolled when a task progress popup was open.
+* Notification query failed on admin users without persons.
+* Querying for notification caused unnecessary database requests.
+* Loading bar didn't disappear on some pages after loading was finished.
+* Support newer versions of django-oauth-toolkit.
+
+`3.1`_ - 2023-05-30
+-------------------
+
+Changed
 ~~~~~~~
 
 * The frontend is now able to display headings in the main toolbar.
@@ -22,16 +83,16 @@ Changes
 Fixed
 ~~~~~
 
-* Default translations from vuetify were not loaded.
+* Default translations from Vuetify were not loaded.
 * Browser locale was not the default locale in the entire frontend.
-* In some cases, some items in the sidenav menu were not shown due to its height being higher than the visible page area.
-* The search bar in the sidenav menu is shown even though the user has no permission to see it.
-* Add permission check to accept invitation menu point in order to hide it when this feature is disabled.
+* In some cases, items in the sidenav menu were not shown.
+* The search bar in the sidenav menu was shown even though the user had no permission to see it.
+* Accept invitation menu item was shown when the invitation feature was disabled.
 * Metrics endpoint for Prometheus was at the wrong URL.
-* Polling behavior of the whoAmI and permission queries was fixed.
+* Polling behavior of the whoAmI and permission queries was improved.
 * Confirmation e-mail contained a wrong link.
 
-`3.0`_ - 2022-05-11
+`3.0`_ - 2023-05-11
 -------------------
 
 Added
@@ -152,13 +213,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`
@@ -184,8 +245,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
 ----------------------
@@ -336,9 +397,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.
@@ -542,11 +601,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
@@ -1120,50 +1177,53 @@ Fixed
 .. _Keep a Changelog: https://keepachangelog.com/en/1.0.0/
 .. _Semantic Versioning: https://semver.org/spec/v2.0.0.html
 
-.. _1.0a1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a1
-.. _1.0a2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a2
-.. _1.0a4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/1.0a4
-.. _2.0a1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0a1
-.. _2.0a2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0a2
-.. _2.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b0
-.. _2.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b1
-.. _2.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0b2
-.. _2.0rc1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc1
-.. _2.0rc2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc2
-.. _2.0rc3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc3
-.. _2.0rc4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc4
-.. _2.0rc5: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc5
-.. _2.0rc6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc6
-.. _2.0rc7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0rc7
-.. _2.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.0
-.. _2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1
-.. _2.1.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.1.1
-.. _2.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2
-.. _2.2.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.2.1
-.. _2.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3
-.. _2.3.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.3.1
-.. _2.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.4
-.. _2.5: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.5
-.. _2.6: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.6
-.. _2.7: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7
-.. _2.7.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.1
-.. _2.7.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.2
-.. _2.7.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.3
-.. _2.7.4: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.7.4
-.. _2.8: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.8
-.. _2.8.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.8.1
-.. _2.9: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.9
-.. _2.10: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10
-.. _2.10.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.1
-.. _2.10.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.10.2
-.. _2.11: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11
-.. _2.11.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.11.1
-.. _2.12: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12
-.. _2.12.1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.1
-.. _2.12.2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.2
-.. _2.12.3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/2.12.3
-.. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b0
-.. _3.0b1: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b1
-.. _3.0b2: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b2
-.. _3.0b3: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0b3
-.. _3.0: https://edugit.org/AlekSIS/Official/AlekSIS/-/tags/3.0
+.. _1.0a1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a1
+.. _1.0a2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a2
+.. _1.0a4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/1.0a4
+.. _2.0a1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0a1
+.. _2.0a2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0a2
+.. _2.0b0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b0
+.. _2.0b1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b1
+.. _2.0b2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0b2
+.. _2.0rc1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc1
+.. _2.0rc2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc2
+.. _2.0rc3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc3
+.. _2.0rc4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc4
+.. _2.0rc5: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc5
+.. _2.0rc6: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc6
+.. _2.0rc7: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0rc7
+.. _2.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.0
+.. _2.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.1
+.. _2.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.1.1
+.. _2.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.2
+.. _2.2.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.2.1
+.. _2.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.3
+.. _2.3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.3.1
+.. _2.4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.4
+.. _2.5: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.5
+.. _2.6: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.6
+.. _2.7: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7
+.. _2.7.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.1
+.. _2.7.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.2
+.. _2.7.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.3
+.. _2.7.4: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.7.4
+.. _2.8: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.8
+.. _2.8.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.8.1
+.. _2.9: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.9
+.. _2.10: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10
+.. _2.10.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10.1
+.. _2.10.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.10.2
+.. _2.11: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.11
+.. _2.11.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.11.1
+.. _2.12: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12
+.. _2.12.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.1
+.. _2.12.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.2
+.. _2.12.3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/2.12.3
+.. _3.0b0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b0
+.. _3.0b1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b1
+.. _3.0b2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b2
+.. _3.0b3: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0b3
+.. _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 a0707ae477aecd56b7d18a01c24d7925735c9f85..2cea6431fa0714ebd4fd303adab620bcda94da92 100644
--- a/aleksis/core/frontend/app/dateTimeFormats.js
+++ b/aleksis/core/frontend/app/dateTimeFormats.js
@@ -33,6 +33,10 @@ const dateTimeFormats = {
       hour: "numeric",
       minute: "numeric",
     },
+    shortTime: {
+      hour: "numeric",
+      minute: "numeric",
+    },
   },
   de: {
     short: {
@@ -67,6 +71,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 790fc10c592bac5acc1e3bbadabcae63e065804f..6dc73749d8bb716bd32089e11afe36f6d93455c9 100644
--- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue
+++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
@@ -21,10 +21,9 @@
   </message-box>
   <iframe
     v-else
-    :src="'/django' + $route.path + queryString"
+    :src="iFrameSrc"
     :height="iFrameHeight + 'px'"
     class="iframe-fullsize"
-    @load="load"
     ref="contentIFrame"
   ></iframe>
 </template>
@@ -41,6 +40,7 @@ export default {
   data: function () {
     return {
       iFrameHeight: 0,
+      iFrameSrc: undefined,
     };
   },
   computed: {
@@ -53,13 +53,16 @@ export default {
     },
   },
   methods: {
+    getIFrameURL() {
+      const location = this.$refs.contentIFrame.contentWindow.location;
+      const url = new URL(location);
+      return url;
+    },
     /** Handle iframe data after inner page loaded */
     load() {
       // Write new location of iframe back to Vue Router
-      const location = this.$refs.contentIFrame.contentWindow.location;
-      const url = new URL(location);
-      const path = url.pathname.replace(/^\/django/, "");
-      const pathWithQueryString = path + encodeURI(url.search);
+      const path = this.getIFrameURL().pathname.replace(/^\/django/, "");
+      const pathWithQueryString = path + encodeURI(this.getIFrameURL().search);
       const routePath =
         path.charAt(path.length - 1) === "/" &&
         this.$route.path.charAt(path.length - 1) !== "/"
@@ -71,7 +74,9 @@ export default {
 
       // Show loader if iframe starts to change its content, even if the $route stays the same
       this.$refs.contentIFrame.contentWindow.onpagehide = () => {
-        this.$root.contentLoading = true;
+        if (this.$root.isLegacyBaseTemplate) {
+          this.$root.contentLoading = true;
+        }
       };
 
       // Write title of iframe to SPA window
@@ -101,15 +106,36 @@ export default {
     },
   },
   watch: {
-    $route() {
+    $route(newRoute) {
       // Show loading animation once route changes
       this.$root.contentLoading = true;
 
+      // Only reload iFrame content when navigation comes from outsite the iFrame
+      const path = this.getIFrameURL().pathname.replace(/^\/django/, "");
+      const routePath =
+        path.charAt(path.length - 1) === "/" &&
+        newRoute.path.charAt(path.length - 1) !== "/"
+          ? newRoute.path + "/"
+          : newRoute.path;
+
+      if (path !== routePath) {
+        this.$refs.contentIFrame.contentWindow.location =
+          "/django" + this.$route.path + this.queryString;
+      } else {
+        this.$root.contentLoading = false;
+      }
+
       // Scroll to top only when route changes to not affect form submits etc.
       // A small duration to avoid flashing of the UI
       this.$vuetify.goTo(0, { duration: 10 });
     },
   },
+  mounted() {
+    this.$refs.contentIFrame.addEventListener("load", (e) => {
+      this.load();
+    });
+    this.iFrameSrc = "/django" + this.$route.path + this.queryString;
+  },
   name: "LegacyBaseTemplate",
 };
 </script>
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/celery_progress/CeleryProgressBottom.vue b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
index 912fb779241ef577c6f900abc78a26008df008f6..1866c3d819ace7f6d329926f6dbe7bdc3b9b0276 100644
--- a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
+++ b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue
@@ -1,5 +1,11 @@
 <template>
-  <v-bottom-sheet :value="show" persistent hide-overlay max-width="400px">
+  <v-bottom-sheet
+    :value="show"
+    persistent
+    hide-overlay
+    max-width="400px"
+    ref="sheet"
+  >
     <v-expansion-panels accordion v-model="open">
       <v-expansion-panel>
         <v-expansion-panel-header color="primary" class="white--text px-4">
@@ -33,6 +39,13 @@ export default {
   data() {
     return { open: 0 };
   },
+  mounted() {
+    // Vuetify uses the hideScroll method to disable scrolling by setting an event listener
+    // to the window. As event listeners can only be removed by referencing the listener
+    // method and because vuetify this method is called on every state change of the dialog,
+    // we simply replace the method in this component instance
+    this.$refs.sheet.hideScroll = this.$refs.sheet.showScroll;
+  },
   computed: {
     show() {
       return this.celeryProgressByUser && this.celeryProgressByUser.length > 0;
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/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue
index 1c51c00661816fe3e244e6d511a0e70c747dbca6..e48d9f2c203a2d2730af1a7208f3590667ede7bf 100644
--- a/aleksis/core/frontend/components/notifications/NotificationList.vue
+++ b/aleksis/core/frontend/components/notifications/NotificationList.vue
@@ -20,7 +20,7 @@
           v-if="
             myNotifications &&
             myNotifications.person &&
-            myNotifications.person.unreadNotificationsCount > 0
+            unreadNotifications.length > 0
           "
         >
           mdi-bell-badge-outline
@@ -86,5 +86,12 @@ export default {
       pollInterval: 30000,
     },
   },
+  computed: {
+    unreadNotifications() {
+      return this.myNotifications.person.notifications
+        ? this.myNotifications.person.notifications.filter((n) => !n.read)
+        : [];
+    },
+  },
 };
 </script>
diff --git a/aleksis/core/frontend/components/notifications/myNotifications.graphql b/aleksis/core/frontend/components/notifications/myNotifications.graphql
index b8287ea2f50664f556d82bdb58e4b508c7ece1d4..89e91562086607e6c9e7649fa1295d234d189bd3 100644
--- a/aleksis/core/frontend/components/notifications/myNotifications.graphql
+++ b/aleksis/core/frontend/components/notifications/myNotifications.graphql
@@ -1,7 +1,6 @@
 {
   myNotifications: whoAmI {
     person {
-      unreadNotificationsCount
       notifications {
         id
         title
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 c700f281d1c0ef0b2f9a0c12a69bb8a5fd47f8ad..d7713a56e7dc0f637952f9c402806124c9b41acb 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -72,10 +72,13 @@
     }
   },
   "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",
     "close": "Close",
@@ -84,7 +87,11 @@
     "copied": "Copied",
     "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": {
@@ -137,9 +144,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",
@@ -239,7 +243,13 @@
   "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",
@@ -259,10 +269,53 @@
     "my_calendars": "My Calendars",
     "download_all": "Download all"
   },
+  "graphql": {
+    "snackbar_error_message": "There was an error retrieving the page data. Please try again.",
+    "snackbar_success_message": "The operation has been finished successfully."
+  },
   "status": {
     "changes": "You have unsaved changes.",
     "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/messages/ru.json b/aleksis/core/frontend/messages/ru.json
index a5a2775a341b0c3f29e2dc12b5fa6d9f93cf8340..161a4efd7b2dafb90d0785a3a69b224dbe6827dd 100644
--- a/aleksis/core/frontend/messages/ru.json
+++ b/aleksis/core/frontend/messages/ru.json
@@ -45,7 +45,24 @@
       "menu_title": "Учётные записи третьих сторон"
     },
     "two_factor": {
-      "menu_title": "2FA"
+      "add_authentication_method": "Добавить метод аутентификации",
+      "backup_codes_count": "У Вас не осталось резервных кодов.|У Вас остался один резервный код.|У Вас осталось {counter} резервных кодов.",
+      "backup_codes_description": "Если Вы не сможете воспользоваться ни одним из своих устройств, Вы сможете получить доступ к учётке, используя резервные коды.",
+      "backup_codes_title": "Резервные коды",
+      "disable_button": "Отключить двухфакторную аутентификацию",
+      "disable_description": "Несмотря на то, что мы крайне предостерегаем Вас, Вы всё же можете отключить двухфакторную аутентификацию для своей учётки.",
+      "disable_title": "Отключить двухфакторную аутентификацию",
+      "enable_button": "Включить двухфакторную аутентификацию",
+      "enable_description": "Двухфакторная аутентификация в Вашей учётке не включена. Для лучшей безопасности, рекомендуем её включить.",
+      "enable_title": "Двухфакторная аутентификация сейчас отключена",
+      "menu_title": "2FA",
+      "methods": {
+        "call": "Мы позвоним на Ваш мобильный и продиктуем одноразовый код.",
+        "email": "Мы отправим Вам одноразовые коды на эл.почту.",
+        "generator": "Вы генерируете одноразовые коды с помощью генератора кодов.",
+        "sms": "Мы отправим Вам одноразовые коды на Ваш мобильный.",
+        "webauthn": "Вы используете ключ безопасности (как внешнее устройство или встроенное в Ваше переносное устройство)."
+      }
     }
   },
   "actions": {
diff --git a/aleksis/core/frontend/messages/uk.json b/aleksis/core/frontend/messages/uk.json
index 6781b5af5e49332b898e1d62762d42e2df2a3f3d..09e2470b8ea214f5afe57ffdfeb1600dc3d888b7 100644
--- a/aleksis/core/frontend/messages/uk.json
+++ b/aleksis/core/frontend/messages/uk.json
@@ -73,9 +73,14 @@
   },
   "actions": {
     "back": "Назад",
+    "cancel": "Скасувати",
     "close": "Закрити",
+    "confirm_deletion": "Ви дійсно хочете видалити цей об'єкт?",
+    "delete": "Видалити",
     "edit": "Редагувати",
-    "search": "Пошук"
+    "save": "Зберегти",
+    "search": "Пошук",
+    "stop_editing": "Завершити редагування"
   },
   "administration": {
     "backend_admin": {
@@ -173,6 +178,17 @@
       "title": "Додаток OAuth2",
       "title_plural": "Додатки OAuth2"
     },
+    "authorized_application": {
+      "access_since": "Доступ з {date}",
+      "description": "Згадані сторонні додатки мають доступ до Вашого облікового запису. Доступ, який більше не потрібен або якому більше не довіряєте, можете відкликати будь-коли.",
+      "has_access_to": "Має доступ до:",
+      "menu_title": "Сторонні додатки",
+      "revoke": "Відкликати доступ",
+      "revoke_question": "Ви дійсно хочете відкликати доступ для цього додатку?",
+      "subtitle": "Сторонні додатки з доступом до Вашого облікового запису",
+      "title": "Сторонні додатки",
+      "valid_until": "Дійсне до {date}"
+    },
     "authorized_token": {
       "menu_title": "Авторизовані додатки"
     }
@@ -225,5 +241,11 @@
     "dismiss": "Відмовитися",
     "new_version_available": "Доступна нова версія програми",
     "update": "Оновити"
+  },
+  "status": {
+    "changes": "У Вас є незбережені зміни.",
+    "error": "Під час збереження останньої зміни виникла помилка.",
+    "saved": "Усі зміни збережені.",
+    "updating": "Зміни синхронізуються."
   }
 }
diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js
index c0228890bb92d21666d3b01c603263391f82d3ad..df94bb0e461e7a01bd76ac55801865d76973bfcb 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/locale/ru/LC_MESSAGES/django.po b/aleksis/core/locale/ru/LC_MESSAGES/django.po
index ac2e3f0fd5a295ab08281388f545cbe3bfed179b..e5977044ca69fff8d2af319bdcc54c59dd11387a 100644
--- a/aleksis/core/locale/ru/LC_MESSAGES/django.po
+++ b/aleksis/core/locale/ru/LC_MESSAGES/django.po
@@ -7,46 +7,44 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-05-09 18:40+0200\n"
-"PO-Revision-Date: 2023-02-08 22:40+0000\n"
+"POT-Creation-Date: 2023-02-27 13:23+0100\n"
+"PO-Revision-Date: 2023-05-26 04:37+0000\n"
 "Last-Translator: Serhii Horichenko <m@sgg.im>\n"
-"Language-Team: Russian <https://translate.edugit.org/projects/aleksis/aleksis-core/ru/>\n"
+"Language-Team: Russian <https://translate.edugit.org/projects/aleksis/"
+"aleksis-core/ru/>\n"
 "Language: ru\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
+"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
+"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n"
+"%100>=11 && n%100<=14)? 2 : 3);\n"
 "X-Generator: Weblate 4.12.1\n"
 
-#: aleksis/core/apps.py:151
-#, fuzzy
-#| msgid "The preferences have been saved successfully."
-msgid "You have been logged out successfully."
-msgstr "Свойства сохранены."
-
-#: aleksis/core/apps.py:161
+#: aleksis/core/apps.py:155 aleksis/core/apps.py:161
 msgid "OpenID Connect scope"
 msgstr "Граница действия OpenID Connect"
 
-#: aleksis/core/apps.py:162
+#: aleksis/core/apps.py:156 aleksis/core/apps.py:162
 msgid "Given name, family name, link to profile and picture if existing."
 msgstr "Имя, фамилия, ссылка на профиль и фото, если есть."
 
-#: aleksis/core/apps.py:163
+#: aleksis/core/apps.py:157 aleksis/core/apps.py:163
 msgid "Full home postal address"
 msgstr "Полный домашний почтовый адрес"
 
-#: aleksis/core/apps.py:164
+#: aleksis/core/apps.py:158 aleksis/core/apps.py:164
 msgid "Email address"
 msgstr "Адрес эл.почты"
 
-#: aleksis/core/apps.py:165
+#: aleksis/core/apps.py:159 aleksis/core/apps.py:165
 msgid "Home and mobile phone"
 msgstr "Домашний и мобильный телефоны"
 
-#: aleksis/core/apps.py:166 aleksis/core/forms.py:221
-#: aleksis/core/models.py:495 aleksis/core/templates/core/group/list.html:8
-#: aleksis/core/templates/core/group/list.html:9
+#: aleksis/core/apps.py:160 aleksis/core/forms.py:220
+#: aleksis/core/models.py:494 aleksis/core/templates/core/group/list.html:8
+#: aleksis/core/templates/core/group/list.html:9 aleksis/core/apps.py:166
+#: aleksis/core/forms.py:221 aleksis/core/models.py:495
 msgid "Groups"
 msgstr "Группы"
 
@@ -104,172 +102,187 @@ msgstr "Разрешение"
 msgid "Content type"
 msgstr "Тип содержимого"
 
-#: aleksis/core/filters.py:113 aleksis/core/models.py:721
+#: aleksis/core/filters.py:113 aleksis/core/models.py:720
+#: aleksis/core/models.py:721
 msgid "User"
 msgstr "Пользователь"
 
-#: aleksis/core/filters.py:135 aleksis/core/models.py:494
+#: aleksis/core/filters.py:135 aleksis/core/models.py:493
+#: aleksis/core/models.py:494
 msgid "Group"
 msgstr "Группа"
 
-#: aleksis/core/forms.py:51 aleksis/core/forms.py:582
+#: aleksis/core/forms.py:50 aleksis/core/forms.py:581 aleksis/core/forms.py:51
+#: aleksis/core/forms.py:582
 msgid "Base data"
 msgstr "Основные данные"
 
-#: aleksis/core/forms.py:56 aleksis/core/tables.py:47
+#: aleksis/core/forms.py:55 aleksis/core/tables.py:47 aleksis/core/forms.py:56
 msgid "Address"
 msgstr "Адрес"
 
-#: aleksis/core/forms.py:57 aleksis/core/forms.py:591
+#: aleksis/core/forms.py:56 aleksis/core/forms.py:590 aleksis/core/forms.py:57
+#: aleksis/core/forms.py:591
 msgid "Contact data"
 msgstr "Контактные данные"
 
-#: aleksis/core/forms.py:59
+#: aleksis/core/forms.py:58 aleksis/core/forms.py:59
 msgid "Advanced personal data"
 msgstr "Дополнительные личные данные"
 
-#: aleksis/core/forms.py:107
+#: aleksis/core/forms.py:106 aleksis/core/forms.py:107
 msgid "New user"
 msgstr "Новый пользователь"
 
-#: aleksis/core/forms.py:107
+#: aleksis/core/forms.py:106 aleksis/core/forms.py:107
 msgid "Create a new account"
 msgstr "Создать новую учётную запись"
 
-#: aleksis/core/forms.py:133
+#: aleksis/core/forms.py:132 aleksis/core/forms.py:133
 msgid "You cannot set a new username when also selecting an existing user."
 msgstr "После выбора существующего пользователя создать новый логин нельзя."
 
-#: aleksis/core/forms.py:137
+#: aleksis/core/forms.py:136 aleksis/core/forms.py:137
 msgid "This username is already in use."
 msgstr "Этот логин уже занят."
 
+#: aleksis/core/forms.py:153 aleksis/core/models.py:141
 #: aleksis/core/forms.py:154 aleksis/core/models.py:142
 msgid "School term"
 msgstr "Учебный год"
 
-#: aleksis/core/forms.py:155
+#: aleksis/core/forms.py:154 aleksis/core/forms.py:155
 msgid "Common data"
 msgstr "Общие данные"
 
-#: aleksis/core/forms.py:156 aleksis/core/forms.py:208
-#: aleksis/core/models.py:165 aleksis/core/templates/core/person/list.html:8
-#: aleksis/core/templates/core/person/list.html:9
+#: aleksis/core/forms.py:155 aleksis/core/forms.py:207
+#: aleksis/core/models.py:164 aleksis/core/templates/core/person/list.html:8
+#: aleksis/core/templates/core/person/list.html:9 aleksis/core/forms.py:156
+#: aleksis/core/forms.py:208 aleksis/core/models.py:165
 msgid "Persons"
 msgstr "Люди"
 
+#: aleksis/core/forms.py:156 aleksis/core/forms.py:592
 #: aleksis/core/forms.py:157 aleksis/core/forms.py:593
 msgid "Additional data"
 msgstr "Дополнительные данные"
 
+#: aleksis/core/forms.py:157 aleksis/core/models.py:217
+#: aleksis/core/models.py:546 aleksis/core/tables.py:46
 #: aleksis/core/forms.py:158 aleksis/core/models.py:218
-#: aleksis/core/models.py:547 aleksis/core/tables.py:46
+#: aleksis/core/models.py:547
 msgid "Photo"
 msgstr "Фото"
 
-#: aleksis/core/forms.py:200 aleksis/core/forms.py:203
-#: aleksis/core/models.py:85
+#: aleksis/core/forms.py:199 aleksis/core/forms.py:202
+#: aleksis/core/models.py:84 aleksis/core/forms.py:200
+#: aleksis/core/forms.py:203 aleksis/core/models.py:85
 msgid "Date"
 msgstr "Дата"
 
-#: aleksis/core/forms.py:201 aleksis/core/forms.py:204
-#: aleksis/core/models.py:93
+#: aleksis/core/forms.py:200 aleksis/core/forms.py:203
+#: aleksis/core/models.py:92 aleksis/core/forms.py:201
+#: aleksis/core/forms.py:204 aleksis/core/models.py:93
 msgid "Time"
 msgstr "Время"
 
-#: aleksis/core/forms.py:234
+#: aleksis/core/forms.py:233 aleksis/core/forms.py:234
 msgid "From when until when should the announcement be displayed?"
 msgstr "С какого и по какое время это объявление должно отображаться?"
 
-#: aleksis/core/forms.py:237
+#: aleksis/core/forms.py:236 aleksis/core/forms.py:237
 msgid "Who should see the announcement?"
 msgstr "Кто должен видеть это объявление?"
 
-#: aleksis/core/forms.py:238
+#: aleksis/core/forms.py:237 aleksis/core/forms.py:238
 msgid "Write your announcement:"
 msgstr "Напишите свое объявление:"
 
-#: aleksis/core/forms.py:277
-msgid "You are not allowed to create announcements which are only valid in the past."
+#: aleksis/core/forms.py:276 aleksis/core/forms.py:277
+msgid ""
+"You are not allowed to create announcements which are only valid in the past."
 msgstr "Объявления для прошлого Вам создавать не разрешено."
 
-#: aleksis/core/forms.py:281
+#: aleksis/core/forms.py:280 aleksis/core/forms.py:281
 msgid "The from date and time must be earlier then the until date and time."
 msgstr "Дата и время начала должны быть до даты и времени окончания."
 
-#: aleksis/core/forms.py:290
+#: aleksis/core/forms.py:289 aleksis/core/forms.py:290
 msgid "You need at least one recipient."
 msgstr "Нужен хотя бы один получатель."
 
-#: aleksis/core/forms.py:399
+#: aleksis/core/forms.py:398 aleksis/core/forms.py:399
 msgid "Invitation code"
 msgstr "Код приглашения"
 
-#: aleksis/core/forms.py:400
+#: aleksis/core/forms.py:399 aleksis/core/forms.py:400
 msgid "Please enter your invitation code."
 msgstr "Укажите, пожалуйста, код приглашения."
 
+#: aleksis/core/forms.py:418 aleksis/core/models.py:192
 #: aleksis/core/forms.py:419 aleksis/core/models.py:193
 msgid "First name"
 msgstr "Имя"
 
+#: aleksis/core/forms.py:419 aleksis/core/models.py:193
 #: aleksis/core/forms.py:420 aleksis/core/models.py:194
 msgid "Last name"
 msgstr "Фамилия"
 
-#: aleksis/core/forms.py:429
+#: aleksis/core/forms.py:428 aleksis/core/forms.py:429
 msgid "A person is using this e-mail address"
 msgstr "Этот эл.адрес кем-то используется"
 
-#: aleksis/core/forms.py:457
+#: aleksis/core/forms.py:456 aleksis/core/forms.py:457
 msgid "Who should get the permission?"
 msgstr "Кто должен получить такое разрешение?"
 
-#: aleksis/core/forms.py:458
+#: aleksis/core/forms.py:457 aleksis/core/forms.py:458
 msgid "On what?"
 msgstr "В случае чего?"
 
-#: aleksis/core/forms.py:484
+#: aleksis/core/forms.py:483 aleksis/core/forms.py:484
 msgid "Select objects which the permission should be granted for:"
 msgstr "Отметьте объекты, к которым будет предоставлен доступ:"
 
-#: aleksis/core/forms.py:487
+#: aleksis/core/forms.py:486 aleksis/core/forms.py:487
 msgid "Grant the permission for all objects"
 msgstr "Предоставить доступ ко всем объектам"
 
-#: aleksis/core/forms.py:495
-msgid "You must select at least one group or person which should get the permission."
+#: aleksis/core/forms.py:494 aleksis/core/forms.py:495
+msgid ""
+"You must select at least one group or person which should get the permission."
 msgstr "Вам нужно выбрать хотя бы одну группу или физлицо, кто получит доступ."
 
-#: aleksis/core/forms.py:500
+#: aleksis/core/forms.py:499 aleksis/core/forms.py:500
 msgid "You must grant the permission to all objects or to specific objects."
 msgstr "Вы должны предоставить доступ ко всем или к конкретным объектам."
 
-#: aleksis/core/forms.py:587
+#: aleksis/core/forms.py:586 aleksis/core/forms.py:587
 msgid "Address data"
 msgstr "Подробности адреса"
 
-#: aleksis/core/forms.py:599
+#: aleksis/core/forms.py:598 aleksis/core/forms.py:599
 msgid "Account data"
 msgstr "Данные учётной записи"
 
-#: aleksis/core/forms.py:606
+#: aleksis/core/forms.py:605 aleksis/core/forms.py:606
 msgid "Password"
 msgstr "Пароль"
 
-#: aleksis/core/forms.py:609
+#: aleksis/core/forms.py:608 aleksis/core/forms.py:609
 msgid "Password (again)"
 msgstr "Пароль (ещё раз)"
 
-#: aleksis/core/forms.py:762
+#: aleksis/core/forms.py:761 aleksis/core/forms.py:762
 msgid "The selected action does not exist."
 msgstr "Выбранное действие не существует."
 
-#: aleksis/core/forms.py:773
+#: aleksis/core/forms.py:772 aleksis/core/forms.py:773
 msgid "You do not have permission to run {} on all selected objects."
 msgstr "У Вас нет разрешения на запуск {} на всех выбранных объектах."
 
-#: aleksis/core/forms.py:829
+#: aleksis/core/forms.py:828 aleksis/core/forms.py:829
 msgid "No valid selection."
 msgstr "Неправильный выбор."
 
@@ -297,658 +310,690 @@ msgstr "Результат резервного копирования не на
 msgid "Linked school term"
 msgstr "Связанный учебный год"
 
-#: aleksis/core/models.py:83
+#: aleksis/core/models.py:82 aleksis/core/models.py:83
 msgid "Boolean (Yes/No)"
 msgstr "Булево (Да/Нет)"
 
-#: aleksis/core/models.py:84
+#: aleksis/core/models.py:83 aleksis/core/models.py:84
 msgid "Text (one line)"
 msgstr "Текст (одна строка)"
 
-#: aleksis/core/models.py:86
+#: aleksis/core/models.py:85 aleksis/core/models.py:86
 msgid "Date and time"
 msgstr "Дата и время"
 
-#: aleksis/core/models.py:87
+#: aleksis/core/models.py:86 aleksis/core/models.py:87
 msgid "Decimal number"
 msgstr "Десятичное число"
 
+#: aleksis/core/models.py:87 aleksis/core/models.py:210
 #: aleksis/core/models.py:88 aleksis/core/models.py:211
 msgid "E-mail address"
 msgstr "Адрес эл.почты"
 
-#: aleksis/core/models.py:89
+#: aleksis/core/models.py:88 aleksis/core/models.py:89
 msgid "Integer"
 msgstr "Целое"
 
-#: aleksis/core/models.py:90
+#: aleksis/core/models.py:89 aleksis/core/models.py:90
 msgid "IP address"
 msgstr "IP адрес"
 
-#: aleksis/core/models.py:91
+#: aleksis/core/models.py:90 aleksis/core/models.py:91
 msgid "Boolean or empty (Yes/No/Neither)"
 msgstr "Булево или пустое (Да/Нет/Ничего)"
 
-#: aleksis/core/models.py:92
+#: aleksis/core/models.py:91 aleksis/core/models.py:92
 msgid "Text (multi-line)"
 msgstr "Текст (многострочный)"
 
-#: aleksis/core/models.py:94
+#: aleksis/core/models.py:93 aleksis/core/models.py:94
 msgid "URL / Link"
 msgstr "URL / Ссылка"
 
+#: aleksis/core/models.py:105 aleksis/core/models.py:1077
 #: aleksis/core/models.py:106 aleksis/core/models.py:1078
 msgid "Name"
-msgstr "Имя"
+msgstr "Полное имя"
 
-#: aleksis/core/models.py:108
+#: aleksis/core/models.py:107 aleksis/core/models.py:108
 msgid "Start date"
 msgstr "Дата начала"
 
-#: aleksis/core/models.py:109
+#: aleksis/core/models.py:108 aleksis/core/models.py:109
 msgid "End date"
 msgstr "Дата окончания"
 
-#: aleksis/core/models.py:128
+#: aleksis/core/models.py:127 aleksis/core/models.py:128
 msgid "The start date must be earlier than the end date."
 msgstr "Дата начала должна быть ранее даты окончания."
 
-#: aleksis/core/models.py:135
+#: aleksis/core/models.py:134 aleksis/core/models.py:135
 msgid "There is already a school term for this time or a part of this time."
 msgstr "На это время или на его часть уже запланирован учебный год."
 
-#: aleksis/core/models.py:143
+#: aleksis/core/models.py:142
 #: aleksis/core/templates/core/school_term/list.html:8
 #: aleksis/core/templates/core/school_term/list.html:9
+#: aleksis/core/models.py:143
 msgid "School terms"
 msgstr "Учебный год"
 
+#: aleksis/core/models.py:163 aleksis/core/models.py:1026
 #: aleksis/core/models.py:164 aleksis/core/models.py:1027
 msgid "Person"
 msgstr "Физлицо"
 
-#: aleksis/core/models.py:167
+#: aleksis/core/models.py:166 aleksis/core/models.py:167
 msgid "Can view address"
 msgstr "Может видеть адрес"
 
-#: aleksis/core/models.py:168
+#: aleksis/core/models.py:167 aleksis/core/models.py:168
 msgid "Can view contact details"
 msgstr "Может видеть контактные данные"
 
-#: aleksis/core/models.py:169
+#: aleksis/core/models.py:168 aleksis/core/models.py:169
 msgid "Can view photo"
 msgstr "Может видеть фото"
 
-#: aleksis/core/models.py:170
+#: aleksis/core/models.py:169 aleksis/core/models.py:170
 msgid "Can view avatar image"
 msgstr "Может видеть аватар"
 
-#: aleksis/core/models.py:171
+#: aleksis/core/models.py:170 aleksis/core/models.py:171
 msgid "Can view persons groups"
 msgstr "Может видеть группы лиц"
 
-#: aleksis/core/models.py:172
+#: aleksis/core/models.py:171 aleksis/core/models.py:172
 msgid "Can view personal details"
 msgstr "Может видеть личные данные"
 
-#: aleksis/core/models.py:182
+#: aleksis/core/models.py:181 aleksis/core/models.py:182
 msgid "female"
 msgstr "жен"
 
-#: aleksis/core/models.py:182
+#: aleksis/core/models.py:181 aleksis/core/models.py:182
 msgid "male"
 msgstr "муж"
 
-#: aleksis/core/models.py:182
+#: aleksis/core/models.py:181 aleksis/core/models.py:182
 msgid "other"
 msgstr "другой"
 
+#: aleksis/core/models.py:189 aleksis/core/models.py:1353
 #: aleksis/core/models.py:190 aleksis/core/models.py:1382
 msgid "Linked user"
 msgstr "Связанный пользователь"
 
-#: aleksis/core/models.py:196
+#: aleksis/core/models.py:195 aleksis/core/models.py:196
 msgid "Additional name(s)"
 msgstr "Дополнительные имена"
 
-#: aleksis/core/models.py:200 aleksis/core/models.py:512
-#: aleksis/core/models.py:1468
+#: aleksis/core/models.py:199 aleksis/core/models.py:511
+#: aleksis/core/models.py:1439 aleksis/core/models.py:200
+#: aleksis/core/models.py:512 aleksis/core/models.py:1468
 msgid "Short name"
 msgstr "Короткое имя"
 
-#: aleksis/core/models.py:203
+#: aleksis/core/models.py:202 aleksis/core/models.py:203
 msgid "Street"
 msgstr "Улица"
 
-#: aleksis/core/models.py:204
+#: aleksis/core/models.py:203 aleksis/core/models.py:204
 msgid "Street number"
 msgstr "Номер дома"
 
-#: aleksis/core/models.py:205
+#: aleksis/core/models.py:204 aleksis/core/models.py:205
 msgid "Postal code"
 msgstr "Почтовый индекс"
 
-#: aleksis/core/models.py:206
+#: aleksis/core/models.py:205 aleksis/core/models.py:206
 msgid "Place"
 msgstr "Город/место"
 
-#: aleksis/core/models.py:208
+#: aleksis/core/models.py:207 aleksis/core/models.py:208
 msgid "Home phone"
 msgstr "Домашний телефон"
 
-#: aleksis/core/models.py:209
+#: aleksis/core/models.py:208 aleksis/core/models.py:209
 msgid "Mobile phone"
 msgstr "Мобильный телефон"
 
-#: aleksis/core/models.py:213
+#: aleksis/core/models.py:212 aleksis/core/models.py:213
 msgid "Date of birth"
 msgstr "Дата рождения"
 
-#: aleksis/core/models.py:214
+#: aleksis/core/models.py:213 aleksis/core/models.py:214
 msgid "Place of birth"
 msgstr "Место рождения"
 
-#: aleksis/core/models.py:215
+#: aleksis/core/models.py:214 aleksis/core/models.py:215
 msgid "Sex"
 msgstr "Пол"
 
+#: aleksis/core/models.py:221 aleksis/core/models.py:550
 #: aleksis/core/models.py:222 aleksis/core/models.py:551
-msgid "This is an official photo, used for official documents and for internal use cases."
-msgstr "Это официальное фото, которое используется для документов и внутренних нужд."
+msgid ""
+"This is an official photo, used for official documents and for internal use "
+"cases."
+msgstr ""
+"Это официальное фото, которое используется для документов и внутренних нужд."
 
+#: aleksis/core/models.py:226 aleksis/core/models.py:554
 #: aleksis/core/models.py:227 aleksis/core/models.py:555
 msgid "Display picture / Avatar"
 msgstr "Отобразить фото/аватар"
 
+#: aleksis/core/models.py:229 aleksis/core/models.py:557
 #: aleksis/core/models.py:230 aleksis/core/models.py:558
 msgid "This is a picture or an avatar for public display."
 msgstr "Это фото или аватар для общего отображения."
 
-#: aleksis/core/models.py:235
+#: aleksis/core/models.py:234 aleksis/core/models.py:235
 msgid "Guardians / Parents"
 msgstr "Опекуны / Родители"
 
-#: aleksis/core/models.py:242
+#: aleksis/core/models.py:241 aleksis/core/models.py:242
 msgid "Primary group"
 msgstr "Основная группа"
 
-#: aleksis/core/models.py:245 aleksis/core/models.py:725
-#: aleksis/core/models.py:749 aleksis/core/models.py:844
-#: aleksis/core/models.py:1113
+#: aleksis/core/models.py:244 aleksis/core/models.py:724
+#: aleksis/core/models.py:748 aleksis/core/models.py:843
+#: aleksis/core/models.py:1112 aleksis/core/models.py:245
+#: aleksis/core/models.py:725 aleksis/core/models.py:749
+#: aleksis/core/models.py:844 aleksis/core/models.py:1113
 msgid "Description"
 msgstr "Описание"
 
-#: aleksis/core/models.py:465
+#: aleksis/core/models.py:464 aleksis/core/models.py:465
 msgid "Title of field"
 msgstr "Название поля"
 
-#: aleksis/core/models.py:467
+#: aleksis/core/models.py:466 aleksis/core/models.py:467
 msgid "Type of field"
 msgstr "Тип поля"
 
-#: aleksis/core/models.py:469
+#: aleksis/core/models.py:468 aleksis/core/models.py:469
 msgid "Required"
 msgstr "Необходимое"
 
-#: aleksis/core/models.py:470
+#: aleksis/core/models.py:469 aleksis/core/models.py:470
 msgid "Help text / description"
 msgstr "Вспомогательный текст / описание"
 
-#: aleksis/core/models.py:476
+#: aleksis/core/models.py:475 aleksis/core/models.py:476
 msgid "Addtitional field for groups"
 msgstr "Дополнительное поле для групп"
 
-#: aleksis/core/models.py:477
+#: aleksis/core/models.py:476 aleksis/core/models.py:477
 msgid "Addtitional fields for groups"
 msgstr "Дополнительные поля для групп"
 
-#: aleksis/core/models.py:497
+#: aleksis/core/models.py:496 aleksis/core/models.py:497
 msgid "Can assign child groups to groups"
 msgstr "Может определять дочерние группы в группы"
 
-#: aleksis/core/models.py:498
+#: aleksis/core/models.py:497 aleksis/core/models.py:498
 msgid "Can view statistics about group."
 msgstr "Может видеть статистику группы."
 
+#: aleksis/core/models.py:509 aleksis/core/models.py:1440
 #: aleksis/core/models.py:510 aleksis/core/models.py:1469
 msgid "Long name"
 msgstr "Длинное имя"
 
-#: aleksis/core/models.py:520 aleksis/core/templates/core/group/full.html:105
+#: aleksis/core/models.py:519 aleksis/core/templates/core/group/full.html:105
+#: aleksis/core/models.py:520
 msgid "Members"
 msgstr "Участники"
 
-#: aleksis/core/models.py:523 aleksis/core/templates/core/group/full.html:102
+#: aleksis/core/models.py:522 aleksis/core/templates/core/group/full.html:102
+#: aleksis/core/models.py:523
 msgid "Owners"
 msgstr "Владельцы"
 
-#: aleksis/core/models.py:530 aleksis/core/templates/core/group/full.html:59
+#: aleksis/core/models.py:529 aleksis/core/templates/core/group/full.html:59
+#: aleksis/core/models.py:530
 msgid "Parent groups"
 msgstr "Родительские группы"
 
-#: aleksis/core/models.py:538
+#: aleksis/core/models.py:537 aleksis/core/models.py:538
 msgid "Type of group"
 msgstr "Тип группы"
 
-#: aleksis/core/models.py:543
+#: aleksis/core/models.py:542
 #: aleksis/core/templates/core/additional_field/list.html:8
 #: aleksis/core/templates/core/additional_field/list.html:9
+#: aleksis/core/models.py:543
 msgid "Additional fields"
 msgstr "Дополнительные поля"
 
+#: aleksis/core/models.py:723 aleksis/core/models.py:747
+#: aleksis/core/models.py:842 aleksis/core/models.py:1270
+#: aleksis/core/templates/core/announcement/list.html:18
 #: aleksis/core/models.py:724 aleksis/core/models.py:748
 #: aleksis/core/models.py:843 aleksis/core/models.py:1299
-#: aleksis/core/templates/core/announcement/list.html:18
 msgid "Title"
 msgstr "Название"
 
-#: aleksis/core/models.py:727
+#: aleksis/core/models.py:726 aleksis/core/models.py:727
 msgid "Application"
 msgstr "Приложение"
 
-#: aleksis/core/models.py:733
+#: aleksis/core/models.py:732 aleksis/core/models.py:733
 msgid "Activity"
 msgstr "Активность"
 
-#: aleksis/core/models.py:734
+#: aleksis/core/models.py:733 aleksis/core/models.py:734
 msgid "Activities"
 msgstr "Активности"
 
-#: aleksis/core/models.py:740
+#: aleksis/core/models.py:739 aleksis/core/models.py:740
 msgid "Sender"
 msgstr "Отправитель"
 
-#: aleksis/core/models.py:745
+#: aleksis/core/models.py:744 aleksis/core/models.py:745
 msgid "Recipient"
 msgstr "Получатель"
 
+#: aleksis/core/models.py:749 aleksis/core/models.py:1078
 #: aleksis/core/models.py:750 aleksis/core/models.py:1079
 msgid "Link"
 msgstr "Ссылка"
 
+#: aleksis/core/models.py:752 aleksis/core/models.py:1079
+#: aleksis/core/models.py:1400
+#: aleksis/core/templates/oauth2_provider/application/detail.html:26
 #: aleksis/core/models.py:753 aleksis/core/models.py:1080
 #: aleksis/core/models.py:1429
-#: aleksis/core/templates/oauth2_provider/application/detail.html:26
 msgid "Icon"
 msgstr "Иконка"
 
-#: aleksis/core/models.py:756
+#: aleksis/core/models.py:755 aleksis/core/models.py:756
 msgid "Send notification at"
 msgstr "Отправить уведомление в"
 
-#: aleksis/core/models.py:758
+#: aleksis/core/models.py:757 aleksis/core/models.py:758
 msgid "Read"
 msgstr "Читать"
 
-#: aleksis/core/models.py:759
+#: aleksis/core/models.py:758 aleksis/core/models.py:759
 msgid "Sent"
 msgstr "Отправлено"
 
-#: aleksis/core/models.py:776
+#: aleksis/core/models.py:775 aleksis/core/models.py:776
 msgid "Notification"
 msgstr "Уведомление"
 
-#: aleksis/core/models.py:777 aleksis/core/preferences.py:29
+#: aleksis/core/models.py:776 aleksis/core/preferences.py:29
+#: aleksis/core/models.py:777
 msgid "Notifications"
 msgstr "Уведомления"
 
-#: aleksis/core/models.py:845
+#: aleksis/core/models.py:844 aleksis/core/models.py:845
 msgid "Link to detailed view"
 msgstr "Ссылка на подробный обзор"
 
-#: aleksis/core/models.py:848
+#: aleksis/core/models.py:847 aleksis/core/models.py:848
 msgid "Date and time from when to show"
 msgstr "Дата и время, с которого показывать"
 
-#: aleksis/core/models.py:851
+#: aleksis/core/models.py:850 aleksis/core/models.py:851
 msgid "Date and time until when to show"
 msgstr "Дата и время, по какое показывать"
 
-#: aleksis/core/models.py:876
+#: aleksis/core/models.py:875 aleksis/core/models.py:876
 msgid "Announcement"
 msgstr "Объявление"
 
-#: aleksis/core/models.py:877
+#: aleksis/core/models.py:876
 #: aleksis/core/templates/core/announcement/list.html:7
 #: aleksis/core/templates/core/announcement/list.html:8
+#: aleksis/core/models.py:877
 msgid "Announcements"
 msgstr "Объявление"
 
-#: aleksis/core/models.py:914
+#: aleksis/core/models.py:913 aleksis/core/models.py:914
 msgid "Announcement recipient"
 msgstr "Получатель объявления"
 
-#: aleksis/core/models.py:915
+#: aleksis/core/models.py:914 aleksis/core/models.py:915
 msgid "Announcement recipients"
 msgstr "Получатели объявления"
 
-#: aleksis/core/models.py:935
+#: aleksis/core/models.py:934 aleksis/core/models.py:935
 msgid "Widget Title"
 msgstr "Название виджета"
 
-#: aleksis/core/models.py:936
+#: aleksis/core/models.py:935 aleksis/core/models.py:936
 msgid "Activate Widget"
 msgstr "Активировать виджет"
 
-#: aleksis/core/models.py:937
+#: aleksis/core/models.py:936 aleksis/core/models.py:937
 msgid "Widget is broken"
 msgstr "Виджет поломался"
 
-#: aleksis/core/models.py:940
+#: aleksis/core/models.py:939 aleksis/core/models.py:940
 msgid "Size on mobile devices"
 msgstr "Размер на мобильных"
 
-#: aleksis/core/models.py:941
+#: aleksis/core/models.py:940 aleksis/core/models.py:941
 msgid "<= 600 px, 12 columns"
 msgstr "<= 600 пикс, 12 столбцов"
 
-#: aleksis/core/models.py:946
+#: aleksis/core/models.py:945 aleksis/core/models.py:946
 msgid "Size on tablet devices"
 msgstr "Размер на планшетах"
 
-#: aleksis/core/models.py:947
+#: aleksis/core/models.py:946 aleksis/core/models.py:947
 msgid "> 600 px, 12 columns"
 msgstr "> 600 пикс, 12 столбцов"
 
-#: aleksis/core/models.py:952
+#: aleksis/core/models.py:951 aleksis/core/models.py:952
 msgid "Size on desktop devices"
 msgstr "Размер на ПК"
 
-#: aleksis/core/models.py:953
+#: aleksis/core/models.py:952 aleksis/core/models.py:953
 msgid "> 992 px, 12 columns"
 msgstr "> 992 пикс, 12 столбцов"
 
-#: aleksis/core/models.py:958
+#: aleksis/core/models.py:957 aleksis/core/models.py:958
 msgid "Size on large desktop devices"
 msgstr "Размер для больших экранов"
 
-#: aleksis/core/models.py:959
+#: aleksis/core/models.py:958 aleksis/core/models.py:959
 msgid "> 1200 px>, 12 columns"
 msgstr "> 1200 пикс, 12 столбцов"
 
-#: aleksis/core/models.py:990
+#: aleksis/core/models.py:989 aleksis/core/models.py:990
 msgid "Can edit default dashboard"
 msgstr "Может редактировать типовую/стандартную информпанель"
 
-#: aleksis/core/models.py:991
+#: aleksis/core/models.py:990 aleksis/core/models.py:991
 msgid "Dashboard Widget"
 msgstr "Виджет информпанели"
 
-#: aleksis/core/models.py:992
+#: aleksis/core/models.py:991 aleksis/core/models.py:992
 msgid "Dashboard Widgets"
 msgstr "Виджеты информпанели"
 
-#: aleksis/core/models.py:998
+#: aleksis/core/models.py:997 aleksis/core/models.py:998
 msgid "URL"
 msgstr "URL"
 
-#: aleksis/core/models.py:999
+#: aleksis/core/models.py:998 aleksis/core/models.py:999
 msgid "Icon URL"
 msgstr "Иконка URL"
 
-#: aleksis/core/models.py:1005
+#: aleksis/core/models.py:1004 aleksis/core/models.py:1005
 msgid "External link widget"
 msgstr "Внешняя ссылка на виджет"
 
-#: aleksis/core/models.py:1006
+#: aleksis/core/models.py:1005 aleksis/core/models.py:1006
 msgid "External link widgets"
 msgstr "Внешние ссылки на виджеты"
 
-#: aleksis/core/models.py:1012
+#: aleksis/core/models.py:1011 aleksis/core/models.py:1012
 msgid "Content"
 msgstr "Содержимое"
 
-#: aleksis/core/models.py:1018
+#: aleksis/core/models.py:1017 aleksis/core/models.py:1018
 msgid "Static content widget"
 msgstr "Виджет с постоянным содержимым"
 
-#: aleksis/core/models.py:1019
+#: aleksis/core/models.py:1018 aleksis/core/models.py:1019
 msgid "Static content widgets"
 msgstr "Виджеты с постоянным содержимым"
 
-#: aleksis/core/models.py:1024
+#: aleksis/core/models.py:1023 aleksis/core/models.py:1024
 msgid "Dashboard widget"
 msgstr "Виджет информпанели"
 
-#: aleksis/core/models.py:1029
+#: aleksis/core/models.py:1028 aleksis/core/models.py:1029
 msgid "Order"
 msgstr "Порядок"
 
-#: aleksis/core/models.py:1030
+#: aleksis/core/models.py:1029 aleksis/core/models.py:1030
 msgid "Part of the default dashboard"
 msgstr "Часть типовой информпанели"
 
-#: aleksis/core/models.py:1045
+#: aleksis/core/models.py:1044 aleksis/core/models.py:1045
 msgid "Dashboard widget order"
 msgstr "Порядок виджета на информпанели"
 
-#: aleksis/core/models.py:1046
+#: aleksis/core/models.py:1045 aleksis/core/models.py:1046
 msgid "Dashboard widget orders"
 msgstr "Порядок виджетов на информпанели"
 
-#: aleksis/core/models.py:1052
+#: aleksis/core/models.py:1051 aleksis/core/models.py:1052
 msgid "Menu ID"
 msgstr "Меню ID"
 
-#: aleksis/core/models.py:1065
+#: aleksis/core/models.py:1064 aleksis/core/models.py:1065
 msgid "Custom menu"
 msgstr "Пользовательское меню"
 
-#: aleksis/core/models.py:1066
+#: aleksis/core/models.py:1065 aleksis/core/models.py:1066
 msgid "Custom menus"
 msgstr "Пользовательские меню"
 
-#: aleksis/core/models.py:1076
+#: aleksis/core/models.py:1075 aleksis/core/models.py:1076
 msgid "Menu"
 msgstr "Меню"
 
-#: aleksis/core/models.py:1086
+#: aleksis/core/models.py:1085 aleksis/core/models.py:1086
 msgid "Custom menu item"
 msgstr "Пункт пользовательского меню"
 
-#: aleksis/core/models.py:1087
+#: aleksis/core/models.py:1086 aleksis/core/models.py:1087
 msgid "Custom menu items"
 msgstr "Пункты пользовательского меню"
 
-#: aleksis/core/models.py:1112
+#: aleksis/core/models.py:1111 aleksis/core/models.py:1112
 msgid "Title of type"
 msgstr "Название типа"
 
-#: aleksis/core/models.py:1119 aleksis/core/templates/core/group/full.html:50
+#: aleksis/core/models.py:1118 aleksis/core/templates/core/group/full.html:50
+#: aleksis/core/models.py:1119
 msgid "Group type"
 msgstr "Тип группы"
 
-#: aleksis/core/models.py:1120
+#: aleksis/core/models.py:1119
 #: aleksis/core/templates/core/group_type/list.html:8
 #: aleksis/core/templates/core/group_type/list.html:9
+#: aleksis/core/models.py:1120
 msgid "Group types"
 msgstr "Типы групп"
 
-#: aleksis/core/models.py:1133
+#: aleksis/core/models.py:1132 aleksis/core/models.py:1133
 msgid "Can view system status"
 msgstr "Может просматривать состояние системы"
 
-#: aleksis/core/models.py:1134
+#: aleksis/core/models.py:1133 aleksis/core/models.py:1134
 msgid "Can manage data"
 msgstr "Может управлять данными"
 
-#: aleksis/core/models.py:1135
+#: aleksis/core/models.py:1134 aleksis/core/models.py:1135
 msgid "Can impersonate"
 msgstr "Может маскироваться"
 
-#: aleksis/core/models.py:1136
+#: aleksis/core/models.py:1135 aleksis/core/models.py:1136
 msgid "Can use search"
 msgstr "Может использовать поиск"
 
-#: aleksis/core/models.py:1137
+#: aleksis/core/models.py:1136 aleksis/core/models.py:1137
 msgid "Can change site preferences"
 msgstr "Может менять свойства сайта"
 
-#: aleksis/core/models.py:1138
+#: aleksis/core/models.py:1137 aleksis/core/models.py:1138
 msgid "Can change person preferences"
 msgstr "Может менять персональные свойства"
 
-#: aleksis/core/models.py:1139
+#: aleksis/core/models.py:1138 aleksis/core/models.py:1139
 msgid "Can change group preferences"
 msgstr "Может менять свойства группы"
 
-#: aleksis/core/models.py:1140
+#: aleksis/core/models.py:1139 aleksis/core/models.py:1140
 msgid "Can test PDF generation"
 msgstr "Может генерировать тестовые PDF"
 
-#: aleksis/core/models.py:1141
+#: aleksis/core/models.py:1140 aleksis/core/models.py:1141
 msgid "Can invite persons"
 msgstr "Может приглашать других"
 
-#: aleksis/core/models.py:1177
+#: aleksis/core/models.py:1176 aleksis/core/models.py:1177
 msgid "Related data check task"
 msgstr "Задание проверки связанных данных"
 
-#: aleksis/core/models.py:1185
+#: aleksis/core/models.py:1184 aleksis/core/models.py:1185
 msgid "Issue solved"
 msgstr "Проблема решена"
 
-#: aleksis/core/models.py:1186
+#: aleksis/core/models.py:1185 aleksis/core/models.py:1186
 msgid "Notification sent"
 msgstr "Уведомление отправлено"
 
-#: aleksis/core/models.py:1199
+#: aleksis/core/models.py:1198 aleksis/core/models.py:1199
 msgid "Data check result"
 msgstr "Результат проверки данных"
 
-#: aleksis/core/models.py:1200
+#: aleksis/core/models.py:1199 aleksis/core/models.py:1200
 msgid "Data check results"
 msgstr "Результаты проверки данных"
 
-#: aleksis/core/models.py:1202
+#: aleksis/core/models.py:1201 aleksis/core/models.py:1202
 msgid "Can run data checks"
 msgstr "Может запускать проверки данных"
 
-#: aleksis/core/models.py:1203
+#: aleksis/core/models.py:1202 aleksis/core/models.py:1203
 msgid "Can solve data check problems"
 msgstr "Может решать проблемы проверки данных"
 
-#: aleksis/core/models.py:1210
+#: aleksis/core/models.py:1209 aleksis/core/models.py:1210
 msgid "E-Mail address"
 msgstr "Адрес эл.почты"
 
-#: aleksis/core/models.py:1270
+#: aleksis/core/models.py:1241 aleksis/core/models.py:1270
 msgid "Owner"
 msgstr "Владелец"
 
-#: aleksis/core/models.py:1274
+#: aleksis/core/models.py:1245 aleksis/core/models.py:1274
 msgid "File expires at"
 msgstr "Файл действителен до"
 
-#: aleksis/core/models.py:1277
+#: aleksis/core/models.py:1248 aleksis/core/models.py:1277
 msgid "Generated HTML file"
 msgstr "Сгенерированный файл HTML"
 
-#: aleksis/core/models.py:1280
+#: aleksis/core/models.py:1251 aleksis/core/models.py:1280
 msgid "Generated PDF file"
 msgstr "Сгенерированный файл PDF"
 
-#: aleksis/core/models.py:1287
+#: aleksis/core/models.py:1258 aleksis/core/models.py:1287
 msgid "PDF file"
 msgstr "Файл PDF"
 
-#: aleksis/core/models.py:1288
+#: aleksis/core/models.py:1259 aleksis/core/models.py:1288
 msgid "PDF files"
 msgstr "Файлы PDF"
 
-#: aleksis/core/models.py:1293
+#: aleksis/core/models.py:1264 aleksis/core/models.py:1293
 msgid "Task result"
 msgstr "Результат задания"
 
-#: aleksis/core/models.py:1296
+#: aleksis/core/models.py:1267 aleksis/core/models.py:1296
 msgid "Task user"
 msgstr "Пользователь задания"
 
-#: aleksis/core/models.py:1300
+#: aleksis/core/models.py:1271 aleksis/core/models.py:1300
 msgid "Back URL"
 msgstr "URL для возврата"
 
-#: aleksis/core/models.py:1301
+#: aleksis/core/models.py:1272 aleksis/core/models.py:1301
 msgid "Progress title"
 msgstr "Название процесса"
 
-#: aleksis/core/models.py:1302
+#: aleksis/core/models.py:1273 aleksis/core/models.py:1302
 msgid "Error message"
 msgstr "Сообщение об ошибке"
 
-#: aleksis/core/models.py:1303
+#: aleksis/core/models.py:1274 aleksis/core/models.py:1303
 msgid "Success message"
 msgstr "Сообщение об успехе"
 
-#: aleksis/core/models.py:1304
+#: aleksis/core/models.py:1275 aleksis/core/models.py:1304
 msgid "Redirect on success URL"
 msgstr "URL для перенаправления в случае успеха"
 
-#: aleksis/core/models.py:1306
+#: aleksis/core/models.py:1277 aleksis/core/models.py:1306
 msgid "Additional button title"
 msgstr "Название дополнительной кнопки"
 
-#: aleksis/core/models.py:1308
+#: aleksis/core/models.py:1279 aleksis/core/models.py:1308
 msgid "Additional button URL"
 msgstr "URL дополнительной кнопки"
 
-#: aleksis/core/models.py:1310
+#: aleksis/core/models.py:1281 aleksis/core/models.py:1310
 msgid "Additional button icon"
 msgstr "Иконка дополнительной кнопки"
 
-#: aleksis/core/models.py:1312
+#: aleksis/core/models.py:1283 aleksis/core/models.py:1312
 msgid "Result fetched"
 msgstr "Полученный результат"
 
-#: aleksis/core/models.py:1337
+#: aleksis/core/models.py:1308 aleksis/core/models.py:1337
 msgid "Background task completed successfully"
 msgstr "Фоновое задание успешно завершено"
 
-#: aleksis/core/models.py:1338
+#: aleksis/core/models.py:1309 aleksis/core/models.py:1338
 msgid "The background task '{}' has been completed successfully."
 msgstr "Фоновое задание \"{}\" успешно завершено."
 
-#: aleksis/core/models.py:1344
+#: aleksis/core/models.py:1315 aleksis/core/models.py:1344
 msgid "Background task failed"
 msgstr "Ошибка фонового задания"
 
-#: aleksis/core/models.py:1345
+#: aleksis/core/models.py:1316 aleksis/core/models.py:1345
 msgid "The background task '{}' has failed."
 msgstr "Ошибка фонового задания \"{}\"."
 
-#: aleksis/core/models.py:1354
+#: aleksis/core/models.py:1325 aleksis/core/models.py:1354
 msgid "Background task"
 msgstr "Фоновое задание"
 
-#: aleksis/core/models.py:1368
+#: aleksis/core/models.py:1339 aleksis/core/models.py:1368
 msgid "Task user assignment"
 msgstr "Назначение пользователя задания"
 
-#: aleksis/core/models.py:1369
+#: aleksis/core/models.py:1340 aleksis/core/models.py:1369
 msgid "Task user assignments"
 msgstr "Назначения пользователей задания"
 
-#: aleksis/core/models.py:1385
+#: aleksis/core/models.py:1356 aleksis/core/models.py:1385
 msgid "Additional attributes"
 msgstr "Дополнительные атрибуты"
 
-#: aleksis/core/models.py:1423
+#: aleksis/core/models.py:1394 aleksis/core/models.py:1423
 msgid "Allowed scopes that clients can request"
 msgstr "Разрешённые пределы действия, которые могут запрашивать клиенты"
 
-#: aleksis/core/models.py:1433
-msgid "This image will be shown as icon in the authorization flow. It should be squared."
-msgstr "Это изображение будет использоваться в качестве значка во время авторизации. Должно быть квадратным."
+#: aleksis/core/models.py:1404 aleksis/core/models.py:1433
+msgid ""
+"This image will be shown as icon in the authorization flow. It should be "
+"squared."
+msgstr ""
+"Это изображение будет использоваться в качестве значка во время авторизации. "
+"Должно быть квадратным."
 
-#: aleksis/core/models.py:1478
+#: aleksis/core/models.py:1449 aleksis/core/models.py:1478
 msgid "Can view room timetable"
 msgstr "Может просмативать расписание комнаты"
 
-#: aleksis/core/models.py:1480
+#: aleksis/core/models.py:1451 aleksis/core/models.py:1480
 msgid "Room"
 msgstr "Комната"
 
-#: aleksis/core/models.py:1481
+#: aleksis/core/models.py:1452 aleksis/core/models.py:1481
 msgid "Rooms"
 msgstr "Комнаты"
 
@@ -1061,8 +1106,11 @@ msgid "Automatically create new persons for new users"
 msgstr "Новые физлица для новых пользователей создавать автоматически"
 
 #: aleksis/core/preferences.py:256
-msgid "Automatically link existing persons to new users by their e-mail address"
-msgstr "Связывать существующие физлица с новыми пользователями автоматически по эл.адресам"
+msgid ""
+"Automatically link existing persons to new users by their e-mail address"
+msgstr ""
+"Связывать существующие физлица с новыми пользователями автоматически по эл."
+"адресам"
 
 #: aleksis/core/preferences.py:267
 msgid "Display name of the school"
@@ -1070,7 +1118,9 @@ msgstr "Название школы / уч.заведения для отобр
 
 #: aleksis/core/preferences.py:278
 msgid "Official name of the school, e.g. as given by supervisory authority"
-msgstr "Официальное название школы / уч.заведения, напр., как в регистрационных документах"
+msgstr ""
+"Официальное название школы / уч.заведения, напр., как в регистрационных "
+"документах"
 
 #: aleksis/core/preferences.py:286
 msgid "Allow users to change their passwords"
@@ -1129,8 +1179,11 @@ msgid "Fields on person model which are editable by themselves."
 msgstr "Поля с описанием физлица, которые можно редактировать самостоятельно."
 
 #: aleksis/core/preferences.py:424
-msgid "Editable fields on person model which should trigger a notification on change"
-msgstr "Изменяемые поля описания физлица, при изменении которых должен срабатывать триггер для уведомления"
+msgid ""
+"Editable fields on person model which should trigger a notification on change"
+msgstr ""
+"Изменяемые поля описания физлица, при изменении которых должен срабатывать "
+"триггер для уведомления"
 
 #: aleksis/core/preferences.py:438
 msgid "Contact for notification if a person changes their data"
@@ -1160,15 +1213,15 @@ msgstr "Автоматически обновлять информпанель 
 msgid "Country for phone number parsing"
 msgstr "Страна для парсинга номера телефона"
 
-#: aleksis/core/settings.py:551
+#: aleksis/core/settings.py:549 aleksis/core/settings.py:551
 msgid "English"
 msgstr "Английский"
 
-#: aleksis/core/settings.py:552
+#: aleksis/core/settings.py:550 aleksis/core/settings.py:552
 msgid "German"
 msgstr "Немецкий"
 
-#: aleksis/core/settings.py:553
+#: aleksis/core/settings.py:551 aleksis/core/settings.py:553
 msgid "Ukrainian"
 msgstr "Украинский"
 
@@ -1180,18 +1233,20 @@ msgid "Edit"
 msgstr "Редактировать"
 
 #: aleksis/core/tables.py:27 aleksis/core/tables.py:148
-#: aleksis/core/tables.py:185
+#: aleksis/core/tables.py:192
 #: aleksis/core/templates/core/announcement/list.html:22
+#: aleksis/core/tables.py:185
 msgid "Actions"
 msgstr "Действия"
 
 #: aleksis/core/tables.py:115 aleksis/core/tables.py:116
 #: aleksis/core/tables.py:130 aleksis/core/tables.py:146
-#: aleksis/core/tables.py:183
+#: aleksis/core/tables.py:190
 #: aleksis/core/templates/core/announcement/list.html:42
 #: aleksis/core/templates/core/group/full.html:33
 #: aleksis/core/templates/core/pages/delete.html:22
 #: aleksis/core/templates/oauth2_provider/application/detail.html:21
+#: aleksis/core/tables.py:183
 msgid "Delete"
 msgstr "Удалить"
 
@@ -1211,12 +1266,14 @@ msgstr ""
 #: aleksis/core/templates/403.html:19 aleksis/core/templates/404.html:16
 msgid ""
 "\n"
-"            If you think this is an error in AlekSIS, please contact your site\n"
+"            If you think this is an error in AlekSIS, please contact your "
+"site\n"
 "            administrators:\n"
 "          "
 msgstr ""
 "\n"
-"            Если Вы думаете, что это ошибка AlekSIS, обратитесь, пожалуйста,\n"
+"            Если Вы думаете, что это ошибка AlekSIS, обратитесь, "
+"пожалуйста,\n"
 "            к администраторам сайта:\n"
 "          "
 
@@ -1243,12 +1300,14 @@ msgstr ""
 #: aleksis/core/templates/500.html:13
 msgid ""
 "\n"
-"            Your site administrators will automatically be notified about this\n"
+"            Your site administrators will automatically be notified about "
+"this\n"
 "            error. You can also contact them directly:\n"
 "          "
 msgstr ""
 "\n"
-"            Администраторы сайта будут уведомлены об этой ошибке автоматически.\n"
+"            Администраторы сайта будут уведомлены об этой ошибке "
+"автоматически.\n"
 "            Вы также можете обратиться к ним непосредственно:\n"
 "          "
 
@@ -1294,13 +1353,21 @@ msgstr "Подтвердить"
 
 #: aleksis/core/templates/account/email_confirm.html:12
 #, python-format
-msgid "Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail address for user %(user_display)s."
-msgstr "Подтвердите, пожалуйста, что <a href=\"mailto:%(email)s\">%(email)s</a> — эл.адрес пользователя %(user_display)s."
+msgid ""
+"Please confirm that <a href=\"mailto:%(email)s\">%(email)s</a> is an e-mail "
+"address for user %(user_display)s."
+msgstr ""
+"Подтвердите, пожалуйста, что <a href=\"mailto:%(email)s\">%(email)s</a> — эл."
+"адрес пользователя %(user_display)s."
 
 #: aleksis/core/templates/account/email_confirm.html:25
 #, python-format
-msgid "This e-mail confirmation link expired or is invalid. Please <a href=\"%(email_url)s\">issue a new e-mail confirmation request</a>."
-msgstr "Эта ссылка для подтверждения эл.почты просрочена или недействительна. Сделайте, пожалуйста, <a href=\"%(email_url)s\">новый запрос</a>."
+msgid ""
+"This e-mail confirmation link expired or is invalid. Please <a href="
+"\"%(email_url)s\">issue a new e-mail confirmation request</a>."
+msgstr ""
+"Эта ссылка для подтверждения эл.почты просрочена или недействительна. "
+"Сделайте, пожалуйста, <a href=\"%(email_url)s\">новый запрос</a>."
 
 #: aleksis/core/templates/account/password_change.html:5
 #: aleksis/core/templates/account/password_change.html:6
@@ -1334,7 +1401,8 @@ msgstr "Изменение пароля отключено."
 msgid ""
 "\n"
 "            Users are not allowed to edit their own passwords. If you think\n"
-"            this is an error please contact one of your site administrators.\n"
+"            this is an error please contact one of your site "
+"administrators.\n"
 "          "
 msgstr ""
 "\n"
@@ -1353,8 +1421,12 @@ msgid "Reset password"
 msgstr "Сбросить пароль"
 
 #: aleksis/core/templates/account/password_reset.html:17
-msgid "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it."
-msgstr "Забыли пароль? Укажите ниже свою эл.почту и мы отправим Вам письмо для сброса пароля."
+msgid ""
+"Forgotten your password? Enter your e-mail address below, and we'll send you "
+"an e-mail allowing you to reset it."
+msgstr ""
+"Забыли пароль? Укажите ниже свою эл.почту и мы отправим Вам письмо для "
+"сброса пароля."
 
 #: aleksis/core/templates/account/password_reset.html:30
 msgid ""
@@ -1377,8 +1449,10 @@ msgid ""
 "          "
 msgstr ""
 "\n"
-"            Мы отправили Вам эл.письмо. Если Вы не получите его на протяжении\n"
-"            нескольких минут, обратитесь, пожалуйста, к администраторам сайта.\n"
+"            Мы отправили Вам эл.письмо. Если Вы не получите его на "
+"протяжении\n"
+"            нескольких минут, обратитесь, пожалуйста, к администраторам "
+"сайта.\n"
 "          "
 
 #: aleksis/core/templates/account/password_reset_from_key.html:15
@@ -1389,13 +1463,16 @@ msgstr "Неправильный токен"
 #, python-format
 msgid ""
 "\n"
-"              The password reset link was invalid, possibly because it has already been used. Please request a <a href=\"%(passwd_reset_url)s\"\n"
+"              The password reset link was invalid, possibly because it has "
+"already been used. Please request a <a href=\"%(passwd_reset_url)s\"\n"
 "              class=\"blue-text text-lighten-2\">new password reset</a>.\n"
 "            "
 msgstr ""
 "\n"
-"              Ссылка на сброс пароля недействительна или, возможно, уже использована. Сделайте, пожалуйста, <a href=\"%(passwd_reset_url)s\"\n"
-"              class=\"blue-text text-lighten-2\">новый запрос на сброс пароля</a>.\n"
+"              Ссылка на сброс пароля недействительна или, возможно, уже "
+"использована. Сделайте, пожалуйста, <a href=\"%(passwd_reset_url)s\"\n"
+"              class=\"blue-text text-lighten-2\">новый запрос на сброс "
+"пароля</a>.\n"
 "            "
 
 #: aleksis/core/templates/account/password_reset_from_key.html:25
@@ -1444,8 +1521,11 @@ msgstr "Регистрация"
 
 #: aleksis/core/templates/account/signup.html:12
 #, python-format
-msgid "Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>."
-msgstr "У Вас уже есть учётная запись? В таком случае можете <a href=\"%(login_url)s\">войти</a>."
+msgid ""
+"Already have an account? Then please <a href=\"%(login_url)s\">sign in</a>."
+msgstr ""
+"У Вас уже есть учётная запись? В таком случае можете <a href=\"%(login_url)s"
+"\">войти</a>."
 
 #: aleksis/core/templates/account/signup.html:22
 #: aleksis/core/templates/socialaccount/signup.html:23
@@ -1489,13 +1569,17 @@ msgstr "Подтвердите свой адрес эл.почты!"
 #: aleksis/core/templates/account/verification_sent.html:16
 msgid ""
 "\n"
-"            This part of the site requires us to verify that you are who you claim to be.\n"
-"            For this purpose, we require that you verify ownership of your e-mail address.\n"
+"            This part of the site requires us to verify that you are who you "
+"claim to be.\n"
+"            For this purpose, we require that you verify ownership of your e-"
+"mail address.\n"
 "          "
 msgstr ""
 "\n"
-"            Для этой части сайта необходимо пройти проверку, что Вы - именно Вы.\n"
-"            Для этого нам нужно проверить как минимум, что адрес эл.почты принадлежит именно Вам\n"
+"            Для этой части сайта необходимо пройти проверку, что Вы - именно "
+"Ð’Ñ‹.\n"
+"            Для этого нам нужно проверить как минимум, что адрес эл.почты "
+"принадлежит именно Вам\n"
 "          "
 
 #: aleksis/core/templates/account/verification_sent.html:22
@@ -1509,7 +1593,8 @@ msgstr ""
 "\n"
 "            Мы отправили Вам для проверки эл.письмо.\n"
 "            Пожалуйста, перейдите по указанной ссылке. Если Вы не получите\n"
-"            письмо в ближайшие несколько минут, обратитесь, пожалуйста, к нам.\n"
+"            письмо в ближайшие несколько минут, обратитесь, пожалуйста, к "
+"нам.\n"
 "          "
 
 #: aleksis/core/templates/core/additional_field/edit.html:6
@@ -1545,7 +1630,7 @@ msgstr "Действительно с"
 
 #: aleksis/core/templates/core/announcement/list.html:20
 msgid "Valid until"
-msgstr "Действительно по"
+msgstr "Действует до"
 
 #: aleksis/core/templates/core/announcement/list.html:21
 msgid "Recipients"
@@ -1608,19 +1693,23 @@ msgstr "Создать %(name)s"
 msgid "Edit default dashboard"
 msgstr "Редактировать стандартную информпанель"
 
+#: aleksis/core/templates/core/data_check/list.html:9
 #: aleksis/core/templates/core/data_check/list.html:10
 #: aleksis/core/templates/core/data_check/list.html:11
 msgid "Data checks"
 msgstr "Проверки данных"
 
+#: aleksis/core/templates/core/data_check/list.html:15
 #: aleksis/core/templates/core/data_check/list.html:16
 msgid "Check data again"
 msgstr "Проверить данные ещё раз"
 
+#: aleksis/core/templates/core/data_check/list.html:22
 #: aleksis/core/templates/core/data_check/list.html:23
 msgid "The system detected some problems with your data."
 msgstr "Система обнаружила некоторые проблемы с Вашими данными."
 
+#: aleksis/core/templates/core/data_check/list.html:23
 #: aleksis/core/templates/core/data_check/list.html:24
 msgid ""
 "Please go through all data and check whether some extra action is\n"
@@ -1629,42 +1718,52 @@ msgstr ""
 "Пожалуйста, пересмотрите внимательно все данные и проверьте не нужно ли\n"
 "          что-то сделать."
 
+#: aleksis/core/templates/core/data_check/list.html:31
 #: aleksis/core/templates/core/data_check/list.html:32
 msgid "Everything is fine."
 msgstr "Всё прекрасно."
 
+#: aleksis/core/templates/core/data_check/list.html:32
 #: aleksis/core/templates/core/data_check/list.html:33
 msgid "The system hasn't detected any problems with your data."
 msgstr "Система не обнаружила никаких проблем с Вашими данными."
 
+#: aleksis/core/templates/core/data_check/list.html:40
 #: aleksis/core/templates/core/data_check/list.html:41
 msgid "Detected problems"
 msgstr "Обнаруженные проблемы"
 
+#: aleksis/core/templates/core/data_check/list.html:45
 #: aleksis/core/templates/core/data_check/list.html:46
 msgid "Affected object"
 msgstr "Зависимые объекты"
 
+#: aleksis/core/templates/core/data_check/list.html:46
 #: aleksis/core/templates/core/data_check/list.html:47
 msgid "Detected problem"
 msgstr "Обнаружена проблема"
 
+#: aleksis/core/templates/core/data_check/list.html:47
 #: aleksis/core/templates/core/data_check/list.html:48
 msgid "Show details"
 msgstr "Подробнее"
 
+#: aleksis/core/templates/core/data_check/list.html:48
 #: aleksis/core/templates/core/data_check/list.html:49
 msgid "Options to solve the problem"
 msgstr "Варианты решения проблемы"
 
+#: aleksis/core/templates/core/data_check/list.html:63
 #: aleksis/core/templates/core/data_check/list.html:65
 msgid "Show object"
 msgstr "Посмотреть объект"
 
+#: aleksis/core/templates/core/data_check/list.html:86
 #: aleksis/core/templates/core/data_check/list.html:89
 msgid "Registered checks"
 msgstr "Зарегистрированные проверки"
 
+#: aleksis/core/templates/core/data_check/list.html:90
 #: aleksis/core/templates/core/data_check/list.html:93
 msgid ""
 "\n"
@@ -1684,29 +1783,39 @@ msgstr "Редактировать информпанель"
 #: aleksis/core/templates/core/edit_dashboard.html:24
 msgid ""
 "\n"
-"          On this page you can arrange your personal dashboard. You can drag any items from \"Available widgets\" to \"Your\n"
-"          Dashboard\" or change the order by moving the widgets. After you have finished, please don't forget to click on\n"
+"          On this page you can arrange your personal dashboard. You can drag "
+"any items from \"Available widgets\" to \"Your\n"
+"          Dashboard\" or change the order by moving the widgets. After you "
+"have finished, please don't forget to click on\n"
 "          \"Save\".\n"
 "        "
 msgstr ""
 "\n"
-"          На этой странице Вы можете упорядочить свою информпанель. Перетаскивайте любые элементы из \"Доступных виджетов\"\n"
-"          в \"Свою информпанель\" или меняйте порядок, перетягивая виджеты. После завершения не забудьте нажать\n"
+"          На этой странице Вы можете упорядочить свою информпанель. "
+"Перетаскивайте любые элементы из \"Доступных виджетов\"\n"
+"          в \"Свою информпанель\" или меняйте порядок, перетягивая виджеты. "
+"После завершения не забудьте нажать\n"
 "          \"Сохранить\".\n"
 "        "
 
 #: aleksis/core/templates/core/edit_dashboard.html:30
 msgid ""
 "\n"
-"          On this page you can arrange the default dashboard which is shown when a user doesn't arrange his own\n"
-"          dashboard. You can drag any items from \"Available widgets\" to \"Default Dashboard\" or change the order\n"
-"          by moving the widgets. After you have finished, please don't forget to click on \"Save\".\n"
+"          On this page you can arrange the default dashboard which is shown "
+"when a user doesn't arrange his own\n"
+"          dashboard. You can drag any items from \"Available widgets\" to "
+"\"Default Dashboard\" or change the order\n"
+"          by moving the widgets. After you have finished, please don't "
+"forget to click on \"Save\".\n"
 "        "
 msgstr ""
 "\n"
-"          На этой странице Вы можете упорядочить типовую/стандартную информпанель, которая отображается, если пользователь\n"
-"          не настроил свою. Перетягивайте любые элементы из \"Доступных виджетов\" в \"Типовую информпанель\" или меняйте \n"
-"          порядок, перетягивая виджеты. После заврешения не забудьте нажать \"Сохранить\".\n"
+"          На этой странице Вы можете упорядочить типовую/стандартную "
+"информпанель, которая отображается, если пользователь\n"
+"          не настроил свою. Перетягивайте любые элементы из \"Доступных "
+"виджетов\" в \"Типовую информпанель\" или меняйте \n"
+"          порядок, перетягивая виджеты. После заврешения не забудьте нажать "
+"\"Сохранить\".\n"
 "        "
 
 #: aleksis/core/templates/core/edit_dashboard.html:48
@@ -1729,13 +1838,16 @@ msgstr "Определить дочерние группы к группе"
 #: aleksis/core/templates/core/group/child_groups.html:18
 msgid ""
 "\n"
-"          You can use this to assign child groups to groups. Please use the filters below to select groups you want to\n"
+"          You can use this to assign child groups to groups. Please use the "
+"filters below to select groups you want to\n"
 "          change and click \"Next\".\n"
 "        "
 msgstr ""
 "\n"
-"          Вы можете воспользоваться этим для привязки дочерних групп к основным. Для выбора групп,\n"
-"          которые хотите изменить, используйте фильтры, расположенные ниже и нажмите \"Далее\".\n"
+"          Вы можете воспользоваться этим для привязки дочерних групп к "
+"основным. Для выбора групп,\n"
+"          которые хотите изменить, используйте фильтры, расположенные ниже и "
+"нажмите \"Далее\".\n"
 "        "
 
 #: aleksis/core/templates/core/group/child_groups.html:31
@@ -1761,7 +1873,8 @@ msgid ""
 "          "
 msgstr ""
 "\n"
-"            Выберите, пожалуйста, несколько групп в порядке, по какому привязывать.\n"
+"            Выберите, пожалуйста, несколько групп в порядке, по какому "
+"привязывать.\n"
 "          "
 
 #: aleksis/core/templates/core/group/child_groups.html:72
@@ -1775,14 +1888,18 @@ msgstr "Пожалуйста, будьте аккуратны!"
 #: aleksis/core/templates/core/group/child_groups.html:79
 msgid ""
 "\n"
-"            If you click \"Back\" or \"Next\" the current group assignments are not saved.\n"
-"            If you click \"Save\", you will overwrite all existing child group relations for this group with what you\n"
+"            If you click \"Back\" or \"Next\" the current group assignments "
+"are not saved.\n"
+"            If you click \"Save\", you will overwrite all existing child "
+"group relations for this group with what you\n"
 "            selected on this page.\n"
 "          "
 msgstr ""
 "\n"
-"            Если нажмёте \"Назад\" или \"Далее\" привязки этой группы не сохранятся.\n"
-"            Если нажмёте \"Сохранить\", все существующие связи дочерней группы с этой группой будут заменены на\n"
+"            Если нажмёте \"Назад\" или \"Далее\" привязки этой группы не "
+"сохранятся.\n"
+"            Если нажмёте \"Сохранить\", все существующие связи дочерней "
+"группы с этой группой будут заменены на\n"
 "            выбранные на этой странице.\n"
 "          "
 
@@ -1880,12 +1997,14 @@ msgstr "Домой"
 #: aleksis/core/templates/core/index.html:34
 msgid ""
 "\n"
-"        You didn't customise your dashboard so that you see the system default. Please click on \"Edit dashboard\" to\n"
+"        You didn't customise your dashboard so that you see the system "
+"default. Please click on \"Edit dashboard\" to\n"
 "        customise your personal dashboard.\n"
 "      "
 msgstr ""
 "\n"
-"        Вы ещё не настроили свою информпанель, так что пока наблюдаете типовую по-умолчанию. Для настройки \n"
+"        Вы ещё не настроили свою информпанель, так что пока наблюдаете "
+"типовую по-умолчанию. Для настройки \n"
 "         своей информпанели клацните \"Редактировать информпанель\".\n"
 "      "
 
@@ -1922,96 +2041,114 @@ msgstr "Состояние системы"
 msgid "System checks"
 msgstr "Системные проверки"
 
+#: aleksis/core/templates/core/pages/system_status.html:22
 #: aleksis/core/templates/core/pages/system_status.html:26
 msgid "Maintenance mode enabled"
 msgstr "Включен режим обслуживания"
 
-#: aleksis/core/templates/core/pages/system_status.html:28
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "                Only admin and visitors from internal IPs can access the site.\n"
-#| "              "
+#: aleksis/core/templates/core/pages/system_status.html:24
 msgid ""
 "\n"
-"                  Only admin and visitors from internal IPs can access the site.\n"
-"                "
+"                Only admin and visitors from internal IPs can access the "
+"site.\n"
+"              "
 msgstr ""
 "\n"
-"                Доступ к сайту имеют только администратор и пользователи с внутренними IP-адресами.\n"
+"                Доступ к сайту имеют только администратор и пользователи с "
+"внутренними IP-адресами.\n"
 "              "
 
+#: aleksis/core/templates/core/pages/system_status.html:36
 #: aleksis/core/templates/core/pages/system_status.html:39
 msgid "Maintenance mode disabled"
 msgstr "Режим обслуживания выключен"
 
+#: aleksis/core/templates/core/pages/system_status.html:37
 #: aleksis/core/templates/core/pages/system_status.html:40
 msgid "Everyone can access the site."
 msgstr "Доступ к сайту есть у всех."
 
+#: aleksis/core/templates/core/pages/system_status.html:47
 #: aleksis/core/templates/core/pages/system_status.html:51
 msgid "Debug mode enabled"
 msgstr "Режим отладки включен"
 
+#: aleksis/core/templates/core/pages/system_status.html:49
 #: aleksis/core/templates/core/pages/system_status.html:53
 msgid ""
 "\n"
-"                The web server throws back debug information on errors. Do not use in production!\n"
+"                The web server throws back debug information on errors. Do "
+"not use in production!\n"
 "              "
 msgstr ""
 "\n"
-"                Веб-сервер во время ошибок пишет отладочную информацию. Не используйте в продакшене!\n"
+"                Веб-сервер во время ошибок пишет отладочную информацию. Не "
+"используйте в продакшене!\n"
 "              "
 
+#: aleksis/core/templates/core/pages/system_status.html:56
 #: aleksis/core/templates/core/pages/system_status.html:60
 msgid "Debug mode disabled"
 msgstr "Режим отладки отключен"
 
+#: aleksis/core/templates/core/pages/system_status.html:58
 #: aleksis/core/templates/core/pages/system_status.html:62
 msgid ""
 "\n"
-"                Debug mode is disabled. Default error pages are displayed on errors.\n"
+"                Debug mode is disabled. Default error pages are displayed on "
+"errors.\n"
 "              "
 msgstr ""
 "\n"
-"                Режим отладки отключен. В случае ошибок будут отображаться стандартные страницы об ошибках.\n"
+"                Режим отладки отключен. В случае ошибок будут отображаться "
+"стандартные страницы об ошибках.\n"
 "              "
 
+#: aleksis/core/templates/core/pages/system_status.html:71
 #: aleksis/core/templates/core/pages/system_status.html:75
 msgid "System health checks"
 msgstr "Проверки работы системы"
 
+#: aleksis/core/templates/core/pages/system_status.html:77
 #: aleksis/core/templates/core/pages/system_status.html:81
 msgid "Service"
 msgstr "Служба"
 
+#: aleksis/core/templates/core/pages/system_status.html:78
+#: aleksis/core/templates/core/pages/system_status.html:119
 #: aleksis/core/templates/core/pages/system_status.html:82
 #: aleksis/core/templates/core/pages/system_status.html:123
 msgid "Status"
 msgstr "Состояние"
 
+#: aleksis/core/templates/core/pages/system_status.html:79
 #: aleksis/core/templates/core/pages/system_status.html:83
 msgid "Time taken"
 msgstr "Продолжительность"
 
+#: aleksis/core/templates/core/pages/system_status.html:100
 #: aleksis/core/templates/core/pages/system_status.html:104
 msgid "seconds"
 msgstr "сек"
 
+#: aleksis/core/templates/core/pages/system_status.html:111
 #: aleksis/core/templates/core/pages/system_status.html:115
 msgid "Celery task results"
 msgstr "Результаты выполнения Celery"
 
-#: aleksis/core/templates/core/pages/system_status.html:120
+#: aleksis/core/templates/core/pages/system_status.html:116
 #: aleksis/core/templates/templated_email/celery_failure.email:9
 #: aleksis/core/templates/templated_email/celery_failure.email:28
+#: aleksis/core/templates/core/pages/system_status.html:120
 msgid "Task"
 msgstr "Задания"
 
+#: aleksis/core/templates/core/pages/system_status.html:117
 #: aleksis/core/templates/core/pages/system_status.html:121
 msgid "ID"
 msgstr "ID"
 
+#: aleksis/core/templates/core/pages/system_status.html:118
 #: aleksis/core/templates/core/pages/system_status.html:122
 msgid "Date done"
 msgstr "Время завершения"
@@ -2024,11 +2161,13 @@ msgstr "Пробное генерирование PDF"
 #: aleksis/core/templates/core/pages/test_pdf.html:14
 msgid ""
 "\n"
-"        This simple view can be used to ensure the correct function of the built-in PDF generation system.\n"
+"        This simple view can be used to ensure the correct function of the "
+"built-in PDF generation system.\n"
 "      "
 msgstr ""
 "\n"
-"        Эта простая страница может помочь проверить корректность настроек встроенной системы генерирования PDF.\n"
+"        Эта простая страница может помочь проверить корректность настроек "
+"встроенной системы генерирования PDF.\n"
 "      "
 
 #: aleksis/core/templates/core/partials/announcements.html:8
@@ -2086,25 +2225,25 @@ msgstr "Неизвестно"
 #: aleksis/core/templates/core/partials/splash_screen.html:11
 msgid ""
 "\n"
-"      This webbrowser doesn't support JavaScript, or its execution is blocked. Please use another browser to continue.\n"
+"      This webbrowser doesn't support JavaScript, or its execution is "
+"blocked. Please use another browser to continue.\n"
 "    "
 msgstr ""
 "\n"
-"      Этот веб-браузер не поддерживает JavaScript или его обработка заблокирована. Для продолжения воспользуйтесь, пожалуйста, другим браузером.\n"
+"      Этот веб-браузер не поддерживает JavaScript или его обработка "
+"заблокирована. Для продолжения воспользуйтесь, пожалуйста, другим "
+"браузером.\n"
 "    "
 
 #: aleksis/core/templates/core/partials/splash_screen.html:17
-#, fuzzy
-#| msgid ""
-#| "The maintenance mode is currently enabled. Please try again\n"
-#| "          later."
 msgid ""
 "\n"
 "      The maintenance mode is currently enabled. Please try again later.\n"
 "    "
 msgstr ""
-"Сайт находится на обслуживании. Попробуйте зайти\n"
-"          позже."
+"\n"
+"      Сайт находится на обслуживании. Попробуйте зайти позже.\n"
+"    "
 
 #: aleksis/core/templates/core/perms/assign.html:12
 #: aleksis/core/templates/core/perms/assign.html:13
@@ -2216,8 +2355,11 @@ msgid "The invite feature is disabled."
 msgstr "Фукция приглашения выключена."
 
 #: aleksis/core/templates/invitations/disabled.html:15
-msgid "To enable it, switch on the corresponding checkbox in the authentication section of the "
-msgstr "Для активации включите соответствующий чекбокс в разделе авторизации на "
+msgid ""
+"To enable it, switch on the corresponding checkbox in the authentication "
+"section of the "
+msgstr ""
+"Для активации включите соответствующий чекбокс в разделе авторизации на "
 
 #: aleksis/core/templates/invitations/disabled.html:16
 msgid "site preferences page"
@@ -2261,10 +2403,12 @@ msgstr "Приглашение по эл.почте"
 msgid "Generate invitation code"
 msgstr "Создать код приглашения"
 
+#: aleksis/core/templates/invitations/forms/_invite.html:29
 #: aleksis/core/templates/invitations/forms/_invite.html:30
 msgid "Generate code"
 msgstr "Генерирование кода"
 
+#: aleksis/core/templates/invitations/forms/_invite.html:33
 #: aleksis/core/templates/invitations/forms/_invite.html:34
 msgid "Invitations"
 msgstr "Приглашения"
@@ -2281,6 +2425,7 @@ msgstr "Регистрация приложения OAuth2"
 
 #: aleksis/core/templates/oauth2_provider/application/create.html:14
 #: aleksis/core/templates/oauth2_provider/application/edit.html:14
+#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:24
 #: aleksis/core/templates/two_factor/_wizard_actions.html:6
 msgid "Cancel"
 msgstr "Отменить"
@@ -2354,31 +2499,52 @@ msgstr "Разрешить"
 msgid "Disallow"
 msgstr "Запретить"
 
+#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:5
+#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:6
+#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:23
+msgid "Revoke access"
+msgstr "Отозвать доступ"
+
+#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:12
+msgid "Are you sure to revoke the access for this application?"
+msgstr "Ви действительно хотите отозвать доступ для этого приложения?"
+
+#: aleksis/core/templates/oauth2_provider/authorized-token-delete.html:20
+msgid "Revoke"
+msgstr "Отозвать"
+
+#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:5
+#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:6
+msgid "Authorized applications"
+msgstr "Авторизованные приложения"
+
+#: aleksis/core/templates/oauth2_provider/authorized-tokens.html:33
+msgid "No authorized applications."
+msgstr "Авторизованных приложений нет."
+
 #: aleksis/core/templates/offline.html:5
 msgid "Network error"
 msgstr "Ошибка сети"
 
 #: aleksis/core/templates/offline.html:10
-msgid "No connection to server."
-msgstr ""
+msgid "Page not available offline."
+msgstr "В автономном режиме страница не доступна."
 
 #: aleksis/core/templates/offline.html:14
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "      This page is not available offline. Since you probably don't have an internet connection, check to see if your WiFi\n"
-#| "      or mobile data is turned on and try again. If you think you are connected, please contact the system\n"
-#| "      administrators:\n"
-#| "    "
 msgid ""
 "\n"
-"      This page is not available without a connection to the server. Please check your internet connection and try again.\n"
-"      If you are connected and the error persists, please contact the system administrators:\n"
+"      This page is not available offline. Since you probably don't have an "
+"internet connection, check to see if your WiFi\n"
+"      or mobile data is turned on and try again. If you think you are "
+"connected, please contact the system\n"
+"      administrators:\n"
 "    "
 msgstr ""
 "\n"
-"      Эта страница в автономном режимен не доступна. Возможно, у Вас проблема с интернетом. Убедитесь, что Ваш WiFi\n"
-"      или мобильный интернет включены и попробуйте ещё раз. Если считаете, что с подключением всё хорошо, обратитесь,\n"
+"      Эта страница в автономном режимен не доступна. Возможно, у Вас "
+"проблема с интернетом. Убедитесь, что Ваш WiFi\n"
+"      или мобильный интернет включены и попробуйте ещё раз. Если считаете, "
+"что с подключением всё хорошо, обратитесь,\n"
 "      пожалуйста, к системным администраторам:\n"
 "    "
 
@@ -2414,12 +2580,14 @@ msgstr "Ошибка входа учётной записи третьей ст
 #: aleksis/core/templates/socialaccount/authentication_error.html:15
 msgid ""
 "\n"
-"            An error occurred while attempting to login via your third-party account.\n"
+"            An error occurred while attempting to login via your third-party "
+"account.\n"
 "            Please contact one of your site administrators.\n"
 "          "
 msgstr ""
 "\n"
-"            Во время попытки входа с Вашей сторонней учётной записью возникла ошибка входа.\n"
+"            Во время попытки входа с Вашей сторонней учётной записью "
+"возникла ошибка входа.\n"
 "            Обратитесь, пожалуйста, к администратору сайта.\n"
 "          "
 
@@ -2434,7 +2602,9 @@ msgstr "Удалить"
 
 #: aleksis/core/templates/socialaccount/connections.html:34
 msgid "You currently have no third-party accounts connected to this account."
-msgstr "Сейчас у Вас нет учётных записей третих сторон, соединённых с этой учётной записью."
+msgstr ""
+"Сейчас у Вас нет учётных записей третих сторон, соединённых с этой учётной "
+"записью."
 
 #: aleksis/core/templates/socialaccount/connections.html:37
 msgid "Add a Third-party Account"
@@ -2464,11 +2634,15 @@ msgstr "Вход отменён"
 #, python-format
 msgid ""
 "\n"
-"            You decided to cancel logging in to our site using one of your existing accounts. If this was a mistake, please proceed to <a href=\"%(login_url)s\">sign in</a>.\n"
+"            You decided to cancel logging in to our site using one of your "
+"existing accounts. If this was a mistake, please proceed to <a href="
+"\"%(login_url)s\">sign in</a>.\n"
 "          "
 msgstr ""
 "\n"
-"            Похоже, Вы отменили вход на наш сайт с одной из Ваших учётных записей. Если это произошло случайно, Вы можете <a href=\"%(login_url)s\">продолжить вход здесь</a>.\n"
+"            Похоже, Вы отменили вход на наш сайт с одной из Ваших учётных "
+"записей. Если это произошло случайно, Вы можете <a href=\"%(login_url)s"
+"\">продолжить вход здесь</a>.\n"
 "          "
 
 #: aleksis/core/templates/socialaccount/signup.html:12
@@ -2478,7 +2652,8 @@ msgid ""
 "        %(site_name)s. As a final step, please complete the following form:"
 msgstr ""
 "Вы на пути к использованию своей учётной записи %(provider_name)s\n"
-"        для входа в %(site_name)s. Заполните, пожалуйста, для завершения эту форму:"
+"        для входа в %(site_name)s. Заполните, пожалуйста, для завершения эту "
+"форму:"
 
 #: aleksis/core/templates/socialaccount/snippets/provider_list.html:12
 #, python-format
@@ -2567,21 +2742,25 @@ msgstr "Система обнаружила новые проблемы с Ва
 #: aleksis/core/templates/templated_email/data_checks.email:6
 msgid ""
 "the system detected some new problems with your data.\n"
-"Please take some time to inspect them and solve the issues or mark them as ignored."
+"Please take some time to inspect them and solve the issues or mark them as "
+"ignored."
 msgstr ""
 "система обнаружила новые проблемы с Вашими данными.\n"
-"Уделите, пожалуйста, немного времени для их проверки и решения проблем, или же отметьте их для игнорирования."
+"Уделите, пожалуйста, немного времени для их проверки и решения проблем, или "
+"же отметьте их для игнорирования."
 
 #: aleksis/core/templates/templated_email/data_checks.email:15
 msgid ""
 "\n"
 "   the system detected some new problems with your data.\n"
-"   Please take some time to inspect them and solve the issues or mark them as ignored.\n"
+"   Please take some time to inspect them and solve the issues or mark them "
+"as ignored.\n"
 "  "
 msgstr ""
 "\n"
 "   система обнаружила новые проблемы с Вашими данными.\n"
-"   Уделите, пожалуйста, немного времени для их проверки и решения проблем, или же отметьте их для игнорирования.\n"
+"   Уделите, пожалуйста, немного времени для их проверки и решения проблем, "
+"или же отметьте их для игнорирования.\n"
 "  "
 
 #: aleksis/core/templates/templated_email/data_checks.email:23
@@ -2592,24 +2771,6 @@ msgstr "Описание проблемы"
 msgid "Count of objects with new problems"
 msgstr "Количество объектов с новыми проблемами"
 
-#: aleksis/core/templates/templated_email/invitation.email:4
-#, python-format
-msgid "Invitation to register on %(site)s"
-msgstr ""
-
-#: aleksis/core/templates/templated_email/invitation.email:6
-#: aleksis/core/templates/templated_email/invitation.email:14
-#, fuzzy, python-format
-#| msgid "Selected persons"
-msgid "Hello %(person)s"
-msgstr "Выбранные физлица"
-
-#: aleksis/core/templates/templated_email/invitation.email:9
-#: aleksis/core/templates/templated_email/invitation.email:18
-#, python-format
-msgid "you have been invited to register on %(site)s. If you would like to accept this invitation, please click on the following link:"
-msgstr ""
-
 #: aleksis/core/templates/templated_email/notification.email:4
 msgid "New notification for"
 msgstr "Новое уведомление для"
@@ -2682,16 +2843,22 @@ msgid ""
 "\n"
 "        Backup tokens can be used when your primary and backup\n"
 "        phone numbers aren't available. The backup tokens below can be used\n"
-"        for login verification. If you've used up all your backup tokens, you\n"
-"        can generate a new set of backup tokens. Only the backup tokens shown\n"
+"        for login verification. If you've used up all your backup tokens, "
+"you\n"
+"        can generate a new set of backup tokens. Only the backup tokens "
+"shown\n"
 "        below will be valid.\n"
 "      "
 msgstr ""
 "\n"
-"        Резервные токены могут быть использованы, когда Ваши основной и резервный\n"
-"        телефонные номера недоступны. Резервные токены, указанные ниже, могут быть\n"
-"        использованы для верификации входа. Когда Вы используете все резервные токены,\n"
-"        Вы сможете сгенерировать новый набор резервных токенов. Действительными будут только\n"
+"        Резервные токены могут быть использованы, когда Ваши основной и "
+"резервный\n"
+"        телефонные номера недоступны. Резервные токены, указанные ниже, "
+"могут быть\n"
+"        использованы для верификации входа. Когда Вы используете все "
+"резервные токены,\n"
+"        Вы сможете сгенерировать новый набор резервных токенов. "
+"Действительными будут только\n"
 "        токены, указанные ниже.\n"
 "      "
 
@@ -2742,7 +2909,9 @@ msgstr ""
 
 #: aleksis/core/templates/two_factor/core/login.html:51
 msgid "Please login with your account to use the external application."
-msgstr "Для использования внешнего приложения войдите, пожалуйста, в свою учётную запись."
+msgstr ""
+"Для использования внешнего приложения войдите, пожалуйста, в свою учётную "
+"запись."
 
 #: aleksis/core/templates/two_factor/core/login.html:58
 msgid "Please login to see this page."
@@ -2751,93 +2920,83 @@ msgstr "Для просмотра этой страницы, пожалуйст
 #: aleksis/core/templates/two_factor/core/login.html:69
 msgid ""
 "\n"
-"                        We are calling your phone right now, please enter the\n"
+"                        We are calling your phone right now, please enter "
+"the\n"
 "                        digits you hear.\n"
 "                      "
 msgstr ""
 "\n"
-"                        Мы сейчас позвоним на Ваш номер. Напишите, пожалуйста, цифры,\n"
+"                        Мы сейчас позвоним на Ваш номер. Напишите, "
+"пожалуйста, цифры,\n"
 "                        которые Вы услышите.\n"
 "                      "
 
 #: aleksis/core/templates/two_factor/core/login.html:74
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "                        We sent you a text message, please enter the tokens we\n"
-#| "                        sent.\n"
-#| "                      "
 msgid ""
 "\n"
-"                        We sent you a text message, please enter the code we sent.\n"
+"                        We sent you a text message, please enter the code we "
+"sent.\n"
 "                      "
 msgstr ""
 "\n"
-"                        Мы отправили Вам текстовое сообщение. Напишите, пожалуйста, полученный\n"
-"                        токен.\n"
+"                        Мы отправили Вам текстовое сообщение. Напишите, "
+"пожалуйста, полученный код.\n"
 "                      "
 
 #: aleksis/core/templates/two_factor/core/login.html:78
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "                        We sent you a text message, please enter the tokens we\n"
-#| "                        sent.\n"
-#| "                      "
 msgid ""
 "\n"
-"                        We sent you an email, please enter the code we sent.\n"
+"                        We sent you an email, please enter the code we "
+"sent.\n"
 "                      "
 msgstr ""
 "\n"
-"                        Мы отправили Вам текстовое сообщение. Напишите, пожалуйста, полученный\n"
-"                        токен.\n"
+"                        Мы отправили Вам на эл.почту код. Введите, "
+"пожалуйста, его.\n"
 "                      "
 
 #: aleksis/core/templates/two_factor/core/login.html:82
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "                    This app is licenced under %(licence)s.\n"
-#| "                  "
 msgid ""
 "\n"
-"                        Please use your Webauthn-compatible device to authenticate.\n"
+"                        Please use your Webauthn-compatible device to "
+"authenticate.\n"
 "                      "
 msgstr ""
 "\n"
-"                    Это приложение под лицензией %(licence)s.\n"
-"                  "
+"                        Пожалуйста, для аутентификации пользуйтесь "
+"устройством, совместимым с Webauthn.\n"
+"                      "
 
 #: aleksis/core/templates/two_factor/core/login.html:86
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "                        Please enter the tokens generated by your token\n"
-#| "                        generator.\n"
-#| "                      "
 msgid ""
 "\n"
-"                        Please enter the code generated by your code generator.\n"
+"                        Please enter the code generated by your code "
+"generator.\n"
 "                      "
 msgstr ""
 "\n"
-"                        Напишите, пожалуйста, токен, с Вашего\n"
-"                        генератора токенов.\n"
+"                        Введите, пожалуйста, токен с Вашего генератора "
+"токенов.\n"
 "                      "
 
 #: aleksis/core/templates/two_factor/core/login.html:91
 msgid ""
 "\n"
-"                      Use this form for entering backup tokens for logging in.\n"
-"                      These tokens have been generated for you to print and keep safe. Please\n"
-"                      enter one of these backup tokens to login to your account.\n"
+"                      Use this form for entering backup tokens for logging "
+"in.\n"
+"                      These tokens have been generated for you to print and "
+"keep safe. Please\n"
+"                      enter one of these backup tokens to login to your "
+"account.\n"
 "                    "
 msgstr ""
 "\n"
-"                      Для входа воспользуйтесь формой для ввода резервных токенов.\n"
-"                      Эти токены были сгенерированы, чтобы Вы распечатали их и сохранили в надёжном месте.\n"
-"                      Для входа укажите, пожалуйста, один из резервных токенов.\n"
+"                      Для входа воспользуйтесь формой для ввода резервных "
+"токенов.\n"
+"                      Эти токены были сгенерированы, чтобы Вы распечатали их "
+"и сохранили в надёжном месте.\n"
+"                      Для входа укажите, пожалуйста, один из резервных "
+"токенов.\n"
 "                    "
 
 #: aleksis/core/templates/two_factor/core/login.html:119
@@ -2845,14 +3004,13 @@ msgid "Device currently not available?"
 msgstr "Сейчас устройство недоступно?"
 
 #: aleksis/core/templates/two_factor/core/login.html:121
-#, fuzzy
-#| msgid "Or, alternatively, use one of your backup phones:"
 msgid "Alternatively, use one of your other authentication methods:"
-msgstr "Или можете воспользоваться одним из резервных телефонов:"
+msgstr "Или можете воспользоваться альтернативным методом аутентификации:"
 
 #: aleksis/core/templates/two_factor/core/login.html:133
 msgid "As a last resort, you can use a backup token:"
-msgstr "В качестве последней возможности можете воспользоваться резервным токеном:"
+msgstr ""
+"В качестве последней возможности можете воспользоваться резервным токеном:"
 
 #: aleksis/core/templates/two_factor/core/login.html:136
 msgid "Use Backup Token"
@@ -2867,19 +3025,18 @@ msgid "Permission Denied"
 msgstr "Доступ отсутствует"
 
 #: aleksis/core/templates/two_factor/core/otp_required.html:10
-#, fuzzy
-#| msgid ""
-#| "The page you requested, enforces users to verify using\n"
-#| "          two-factor authentication for security reasons. You need to enable these\n"
-#| "          security features in order to access this page."
 msgid ""
 "The page you requested enforces users to verify using\n"
-"          two-factor authentication for security reasons. You need to enable this\n"
+"          two-factor authentication for security reasons. You need to enable "
+"this\n"
 "          security feature in order to access this page."
 msgstr ""
-"Для просмотра запрошенной страницы, с оглядкой на безопасность, необходима дополнительная\n"
-"          проверка пользователя с использованием двухфакторной аутентификации.\n"
-"          Для доступа к данной странице Вы должны включить эти функции безопасности."
+"Для просмотра запрошенной страницы, с оглядкой на безопасность, необходима "
+"дополнительная\n"
+"          проверка пользователя с использованием двухфакторной "
+"аутентификации.\n"
+"          Для доступа к данной странице Вы должны включить эти функции "
+"безопасности."
 
 #: aleksis/core/templates/two_factor/core/otp_required.html:16
 msgid "Go back"
@@ -2909,19 +3066,10 @@ msgstr ""
 "      пожалуйста, полученный токен."
 
 #: aleksis/core/templates/two_factor/core/setup.html:9
-#, fuzzy
-#| msgid "Enable Two-Factor Authentication"
 msgid "Add Two-Factor Authentication Method"
-msgstr "Включить двух-факторную аутентификацию"
+msgstr "Добавить двухфакторную аутентификацию"
 
 #: aleksis/core/templates/two_factor/core/setup.html:13
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "        You are about to take your account security to the\n"
-#| "        next level. Follow the steps in this wizard to enable two-factor\n"
-#| "        authentication.\n"
-#| "      "
 msgid ""
 "\n"
 "        You are about to take your account security to the\n"
@@ -2930,8 +3078,10 @@ msgid ""
 "      "
 msgstr ""
 "\n"
-"        Вы устанавливаете для своей учётной записи новый уровень безопасности.\n"
-"        Для включения двухфакторной аутентификации пройдите несколько шагов\n"
+"        Вы устанавливаете для своей учётной записи новый уровень "
+"безопасности.\n"
+"        Для добавления двухфакторной аутентификации пройдите несколько "
+"шагов\n"
 "        мастера настройки.\n"
 "      "
 
@@ -2942,28 +3092,25 @@ msgid ""
 "      "
 msgstr ""
 "\n"
-"        Выберите, пожалуйста, метод аутентификации, который Вы хотите использовать:\n"
+"        Выберите, пожалуйста, метод аутентификации, который Вы хотите "
+"использовать:\n"
 "      "
 
 #: aleksis/core/templates/two_factor/core/setup.html:27
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "        To start using a token generator, please use your\n"
-#| "        favourite two factor authentication (TOTP) app to scan the QR code below.\n"
-#| "        Then, enter the token generated by the app.\n"
-#| "      "
 msgid ""
 "\n"
 "        To start using a code generator, please use your\n"
-"        favourite two-factor authentication (TOTP) app to scan the QR code below.\n"
+"        favourite two-factor authentication (TOTP) app to scan the QR code "
+"below.\n"
 "        Then enter the token generated by the app.\n"
 "      "
 msgstr ""
 "\n"
-"        Для того, чтобы начать использование генератора токенов, воспользуйтесь, пожалуйста, своим\n"
-"        любимым приложением для двухфакторной аутентификации (TOTP) и отсканируйте QR-код, который видите ниже.\n"
-"        После этого напишите полученный генератором токен.\n"
+"        Для того, чтобы начать пользоваться генератором кодов, "
+"воспользуйтесь, пожалуйста, своим\n"
+"        любимым приложением для двухфакторной аутентификации (TOTP) и "
+"отсканируйте QR-код, который видите ниже.\n"
+"        После этого напишите сгенерированный программой токен.\n"
 "      "
 
 #: aleksis/core/templates/two_factor/core/setup.html:38
@@ -2991,58 +3138,57 @@ msgstr ""
 "      "
 
 #: aleksis/core/templates/two_factor/core/setup.html:52
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "            We sent you a text message, please enter the tokens we sent.\n"
-#| "          "
 msgid ""
 "\n"
 "        We sent you an email, please enter the token we sent.\n"
 "      "
 msgstr ""
 "\n"
-"            Мы отправили Вам текстовое сообщение. Напишите, пожалуйста, полученные токены.\n"
-"          "
+"        Мы отправили Вам эл.письмо. Напишите, пожалуйста, полученный токен.\n"
+"      "
 
 #: aleksis/core/templates/two_factor/core/setup.html:60
 msgid ""
 "\n"
-"            We are calling your phone right now, please enter the digits you hear.\n"
+"            We are calling your phone right now, please enter the digits you "
+"hear.\n"
 "          "
 msgstr ""
 "\n"
-"            Мы сейчас звоним на Ваш номер, – напишите, пожалуйста, цифры, которые услышите.\n"
+"            Мы сейчас звоним на Ваш номер, – напишите, пожалуйста, цифры, "
+"которые услышите.\n"
 "          "
 
 #: aleksis/core/templates/two_factor/core/setup.html:66
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "            We sent you a text message, please enter the tokens we sent.\n"
-#| "          "
 msgid ""
 "\n"
 "            We sent you a text message, please enter the code we sent.\n"
 "          "
 msgstr ""
 "\n"
-"            Мы отправили Вам текстовое сообщение. Напишите, пожалуйста, полученные токены.\n"
+"            Мы отправили Вам текстовое сообщение. Напишите, пожалуйста, "
+"полученный код.\n"
 "          "
 
 #: aleksis/core/templates/two_factor/core/setup.html:73
 msgid ""
 "\n"
-"          We've encountered an issue with the selected authentication method. Please\n"
-"          go back and verify that you entered your information correctly, try\n"
-"          again, or use a different authentication method instead. If the issue\n"
+"          We've encountered an issue with the selected authentication "
+"method. Please\n"
+"          go back and verify that you entered your information correctly, "
+"try\n"
+"          again, or use a different authentication method instead. If the "
+"issue\n"
 "          persists, contact the site administrator.\n"
 "        "
 msgstr ""
 "\n"
-"          Мы заметили, что существует проблема с использованием выбранного метода авторизации. Вернитесь,\n"
-"          пожалуйста, назад, и убедитесь, что необходимые данные указаны правильно,\n"
-"          после чего попробуйте зайти ешё раз, или же воспользуйтесь другим вариантом входа. Если же\n"
+"          Мы заметили, что существует проблема с использованием выбранного "
+"метода авторизации. Вернитесь,\n"
+"          пожалуйста, назад, и убедитесь, что необходимые данные указаны "
+"правильно,\n"
+"          после чего попробуйте зайти ешё раз, или же воспользуйтесь другим "
+"вариантом входа. Если же\n"
 "          проблема остаётся, обратитесь к администратору сайта.\n"
 "        "
 
@@ -3068,7 +3214,8 @@ msgstr "Двухфакторная аутентификация успешно 
 #: aleksis/core/templates/two_factor/core/setup_complete.html:13
 msgid ""
 "\n"
-"        Congratulations, you've successfully enabled two-factor authentication.\n"
+"        Congratulations, you've successfully enabled two-factor "
+"authentication.\n"
 "      "
 msgstr ""
 "\n"
@@ -3086,29 +3233,25 @@ msgid "Generate backup codes"
 msgstr "Создать резервные коды"
 
 #: aleksis/core/templates/two_factor/core/setup_complete.html:31
-#, fuzzy
-#| msgid ""
-#| "\n"
-#| "          However, it might happen that you don't have access to\n"
-#| "          your primary token device. To enable account recovery, generate backup codes\n"
-#| "          or add a phone number.\n"
-#| "        "
 msgid ""
 "\n"
 "          However, it might happen that you don't have access to\n"
-"          your primary device. To enable account recovery, generate backup codes\n"
+"          your primary device. To enable account recovery, generate backup "
+"codes\n"
 "          or add other authentication methods.\n"
 "        "
 msgstr ""
 "\n"
-"          Между прочим, может так случиться, что у Вас не будет доступа к своему основному\n"
-"          устройству с токенами. Для включения восстановления учётки создайте резервные коды\n"
-"          или добавьте номер телефона.\n"
+"          Между прочим, может так случиться, что у Вас не будет доступа к "
+"своему основному\n"
+"          устройству. Для включения восстановления учётки создайте резервные "
+"коды\n"
+"          или добавьте другие методы аутентификации.\n"
 "        "
 
 #: aleksis/core/templates/two_factor/core/setup_complete.html:48
 msgid "Add Another Authentication Method"
-msgstr ""
+msgstr "Добавить метод аутентификации"
 
 #: aleksis/core/templates/two_factor/profile/disable.html:5
 #: aleksis/core/templates/two_factor/profile/disable.html:9
@@ -3116,8 +3259,12 @@ msgid "Disable Two-Factor Authentication"
 msgstr "Отключить двухфакторную аутентификацию"
 
 #: aleksis/core/templates/two_factor/profile/disable.html:12
-msgid "You are about to disable two-factor authentication. This weakens your account security, are you sure?"
-msgstr "Вы отключаете двухфакторную аутентификацию. Это снизит защиту Вашей учётной записи. Вы уверены?"
+msgid ""
+"You are about to disable two-factor authentication. This weakens your "
+"account security, are you sure?"
+msgstr ""
+"Вы отключаете двухфакторную аутентификацию. Это снизит защиту Вашей учётной "
+"записи. Вы уверены?"
 
 #: aleksis/core/templates/two_factor/profile/disable.html:26
 msgid "Disable"
@@ -3151,187 +3298,238 @@ msgstr "Во время создания файла PDF возникла про
 msgid "Download PDF"
 msgstr "Скачать PDF"
 
-#: aleksis/core/views.py:285
+#: aleksis/core/views.py:280 aleksis/core/views.py:285
 msgid "The school term has been created."
 msgstr "Учебный год создан."
 
-#: aleksis/core/views.py:297
+#: aleksis/core/views.py:292 aleksis/core/views.py:297
 msgid "The school term has been saved."
 msgstr "Учебный год сохранён."
 
-#: aleksis/core/views.py:401
+#: aleksis/core/views.py:396 aleksis/core/views.py:401
 msgid "The child groups were successfully saved."
 msgstr "Дочерние группы сохранены."
 
+#: aleksis/core/views.py:415 aleksis/core/views.py:425
 #: aleksis/core/views.py:420 aleksis/core/views.py:430
 msgid "The person has been saved."
 msgstr "Физлицо сохранено."
 
-#: aleksis/core/views.py:480
+#: aleksis/core/views.py:475 aleksis/core/views.py:480
 msgid "The group has been saved."
 msgstr "Группа сохранена."
 
-#: aleksis/core/views.py:528
-#, fuzzy
-#| msgid "The data checks were run successfully."
-msgid "Maintenance mode was turned on successfully."
-msgstr "Проверка данных успешно запущена."
-
-#: aleksis/core/views.py:530
-msgid "Maintenance mode was turned off successfully."
-msgstr ""
-
-#: aleksis/core/views.py:588
+#: aleksis/core/views.py:558 aleksis/core/views.py:588
 msgid "The announcement has been saved."
 msgstr "Объявление сохранено."
 
-#: aleksis/core/views.py:604
+#: aleksis/core/views.py:574 aleksis/core/views.py:604
 msgid "The announcement has been deleted."
 msgstr "Объявление удалено."
 
-#: aleksis/core/views.py:673
+#: aleksis/core/views.py:643 aleksis/core/views.py:673
 msgid "The requested preference registry does not exist"
 msgstr "Журнал с запрошенными свойствами не существует"
 
-#: aleksis/core/views.py:692
+#: aleksis/core/views.py:662 aleksis/core/views.py:692
 msgid "The preferences have been saved successfully."
 msgstr "Свойства сохранены."
 
-#: aleksis/core/views.py:716
+#: aleksis/core/views.py:686 aleksis/core/views.py:716
 msgid "The person has been deleted."
 msgstr "Физлицо удалено."
 
-#: aleksis/core/views.py:730
+#: aleksis/core/views.py:700 aleksis/core/views.py:730
 msgid "The group has been deleted."
 msgstr "Группа удалена."
 
-#: aleksis/core/views.py:762
+#: aleksis/core/views.py:732 aleksis/core/views.py:762
 msgid "The additional field has been saved."
 msgstr "Дополнительное поле сохранено."
 
-#: aleksis/core/views.py:797
+#: aleksis/core/views.py:767 aleksis/core/views.py:797
 msgid "The additional field has been deleted."
 msgstr "Дополнительное поле удалено."
 
-#: aleksis/core/views.py:822
+#: aleksis/core/views.py:792 aleksis/core/views.py:822
 msgid "The group type has been saved."
 msgstr "Тип группы сохранён."
 
-#: aleksis/core/views.py:853
+#: aleksis/core/views.py:823 aleksis/core/views.py:853
 msgid "The group type has been deleted."
 msgstr "Тип группы удалён."
 
-#: aleksis/core/views.py:888
+#: aleksis/core/views.py:858 aleksis/core/views.py:888
 msgid "Progress: Run data checks"
 msgstr "В процессе: Запуск проверки данных"
 
-#: aleksis/core/views.py:889
+#: aleksis/core/views.py:859 aleksis/core/views.py:889
 msgid "Run data checks …"
 msgstr "Запускается проверка данных …"
 
-#: aleksis/core/views.py:890
+#: aleksis/core/views.py:860 aleksis/core/views.py:890
 msgid "The data checks were run successfully."
 msgstr "Проверка данных успешно запущена."
 
-#: aleksis/core/views.py:891
+#: aleksis/core/views.py:861 aleksis/core/views.py:891
 msgid "There was a problem while running data checks."
 msgstr "Во время запуска проверки данных возникла проблема."
 
-#: aleksis/core/views.py:908
+#: aleksis/core/views.py:878 aleksis/core/views.py:908
 #, python-brace-format
 msgid "The solve option '{solve_option_obj.verbose_name}' "
 msgstr "Вариант решения \"{solve_option_obj.verbose_name}\" "
 
-#: aleksis/core/views.py:918
+#: aleksis/core/views.py:888 aleksis/core/views.py:918
 msgid "The requested solve option does not exist"
 msgstr "Запрошенный вариант решения не существует"
 
-#: aleksis/core/views.py:951
+#: aleksis/core/views.py:921 aleksis/core/views.py:951
 msgid "The dashboard widget has been saved."
 msgstr "Виджет информпанели сохранён."
 
-#: aleksis/core/views.py:981
+#: aleksis/core/views.py:951 aleksis/core/views.py:981
 msgid "The dashboard widget has been created."
 msgstr "Виджет информпанели создан."
 
-#: aleksis/core/views.py:991
+#: aleksis/core/views.py:961 aleksis/core/views.py:991
 msgid "The dashboard widget has been deleted."
 msgstr "Виджет информпанели удалён."
 
-#: aleksis/core/views.py:1063
+#: aleksis/core/views.py:1033 aleksis/core/views.py:1063
 msgid "Your dashboard configuration has been saved successfully."
 msgstr "Ваша конфигурация информпанели сохранена."
 
-#: aleksis/core/views.py:1065
+#: aleksis/core/views.py:1035 aleksis/core/views.py:1065
 msgid "The configuration of the default dashboard has been saved successfully."
 msgstr "Конфигурация типовой/стандартной информпанели."
 
-#: aleksis/core/views.py:1136
+#: aleksis/core/views.py:1106 aleksis/core/views.py:1136
 #, python-brace-format
 msgid "The invitation was successfully created. The invitation code is {code}"
 msgstr "Приглашение успешно создано. Код приглашения: {code}"
 
-#: aleksis/core/views.py:1233
+#: aleksis/core/views.py:1203 aleksis/core/views.py:1233
 msgid "We have successfully assigned the permissions."
 msgstr "Мы успешно назначили доступы."
 
-#: aleksis/core/views.py:1243
+#: aleksis/core/views.py:1213 aleksis/core/views.py:1243
 msgid "The global user permission has been deleted."
 msgstr "Глобальный пользовательский доступ удалён."
 
-#: aleksis/core/views.py:1253
+#: aleksis/core/views.py:1223 aleksis/core/views.py:1253
 msgid "The global group permission has been deleted."
 msgstr "Глобальный групповой доступ удалён."
 
-#: aleksis/core/views.py:1263
+#: aleksis/core/views.py:1233 aleksis/core/views.py:1263
 msgid "The object user permission has been deleted."
 msgstr "Объектный пользовательский доступ удалён."
 
-#: aleksis/core/views.py:1273
+#: aleksis/core/views.py:1243 aleksis/core/views.py:1273
 msgid "The object group permission has been deleted."
 msgstr "Объектный групповой доступ удалён."
 
-#: aleksis/core/views.py:1382
-msgid "The third-party account could not be disconnected because it is the only login method available."
-msgstr "Учётную запись третьей стороны нельзя отключить, т.к. это единственный способ входа."
+#: aleksis/core/views.py:1352 aleksis/core/views.py:1382
+msgid ""
+"The third-party account could not be disconnected because it is the only "
+"login method available."
+msgstr ""
+"Учётную запись третьей стороны нельзя отключить, т.к. это единственный "
+"способ входа."
 
-#: aleksis/core/views.py:1389
+#: aleksis/core/views.py:1359 aleksis/core/views.py:1389
 msgid "The third-party account has been successfully disconnected."
 msgstr "Учётная запись третьей стороны успешно отключена."
 
-#: aleksis/core/views.py:1465
-msgid "Person was invited successfully and an email with further instructions has been send to them."
-msgstr "Владелец указанного эл.адреса успешно приглашён. Инструкции о дальнейших действиях отправлены на эл.почту."
+#: aleksis/core/views.py:1435 aleksis/core/views.py:1465
+msgid ""
+"Person was invited successfully and an email with further instructions has "
+"been send to them."
+msgstr ""
+"Владелец указанного эл.адреса успешно приглашён. Инструкции о дальнейших "
+"действиях отправлены на эл.почту."
 
-#: aleksis/core/views.py:1476
+#: aleksis/core/views.py:1446 aleksis/core/views.py:1476
 msgid "Person was already invited."
 msgstr "Кто-то уже пригласил его/её."
 
-#~ msgid "Revoke access"
-#~ msgstr "Отозвать доступ"
+#: aleksis/core/apps.py:151
+#, fuzzy
+msgid "You have been logged out successfully."
+msgstr "Свойства сохранены."
 
-#~ msgid "Are you sure to revoke the access for this application?"
-#~ msgstr "Ви действительно хотите отозвать доступ для этого приложения?"
+#: aleksis/core/templates/core/pages/system_status.html:28
+#, fuzzy
+msgid ""
+"\n"
+"                  Only admin and visitors from internal IPs can access the "
+"site.\n"
+"                "
+msgstr ""
+"\n"
+"                Доступ к сайту имеют только администратор и пользователи с "
+"внутренними IP-адресами.\n"
+"              "
+
+#: aleksis/core/templates/offline.html:10
+msgid "No connection to server."
+msgstr ""
+
+#: aleksis/core/templates/offline.html:14
+#, fuzzy
+msgid ""
+"\n"
+"      This page is not available without a connection to the server. Please "
+"check your internet connection and try again.\n"
+"      If you are connected and the error persists, please contact the system "
+"administrators:\n"
+"    "
+msgstr ""
+"\n"
+"      Эта страница в автономном режимен не доступна. Возможно, у Вас "
+"проблема с интернетом. Убедитесь, что Ваш WiFi\n"
+"      или мобильный интернет включены и попробуйте ещё раз. Если считаете, "
+"что с подключением всё хорошо, обратитесь,\n"
+"      пожалуйста, к системным администраторам:\n"
+"    "
+
+#: aleksis/core/templates/templated_email/invitation.email:4
+#, python-format
+msgid "Invitation to register on %(site)s"
+msgstr ""
 
-#~ msgid "Revoke"
-#~ msgstr "Отозвать"
+#: aleksis/core/templates/templated_email/invitation.email:6
+#: aleksis/core/templates/templated_email/invitation.email:14
+#, fuzzy, python-format
+msgid "Hello %(person)s"
+msgstr "Выбранные физлица"
 
-#~ msgid "Authorized applications"
-#~ msgstr "Авторизованные приложения"
+#: aleksis/core/templates/templated_email/invitation.email:9
+#: aleksis/core/templates/templated_email/invitation.email:18
+#, python-format
+msgid ""
+"you have been invited to register on %(site)s. If you would like to accept "
+"this invitation, please click on the following link:"
+msgstr ""
 
-#~ msgid "No authorized applications."
-#~ msgstr "Авторизованных приложений нет."
+#: aleksis/core/views.py:528
+#, fuzzy
+msgid "Maintenance mode was turned on successfully."
+msgstr "Проверка данных успешно запущена."
 
-#~ msgid "Page not available offline."
-#~ msgstr "В автономном режиме страница не доступна."
+#: aleksis/core/views.py:530
+msgid "Maintenance mode was turned off successfully."
+msgstr ""
 
 #~ msgid ""
 #~ "\n"
-#~ "            This page is currently unavailable. If this error persists, contact your site administrators:\n"
+#~ "            This page is currently unavailable. If this error persists, "
+#~ "contact your site administrators:\n"
 #~ "          "
 #~ msgstr ""
 #~ "\n"
-#~ "            Эта страница сейчас недоступна. Если ошибка проявится ещё раз, обратитесь к администраторам сайта:\n"
+#~ "            Эта страница сейчас недоступна. Если ошибка проявится ещё "
+#~ "раз, обратитесь к администраторам сайта:\n"
 #~ "          "
 
 #~ msgid ""
@@ -3415,8 +3613,10 @@ msgstr "Кто-то уже пригласил его/её."
 #~ "      "
 #~ msgstr ""
 #~ "\n"
-#~ "        Не смотря на то, что мы Вам рекомендуем этого не делать, Вы можете \n"
-#~ "        также отключить двухфакторную аутентификацию для своей учётной записи.\n"
+#~ "        Не смотря на то, что мы Вам рекомендуем этого не делать, Вы "
+#~ "можете \n"
+#~ "        также отключить двухфакторную аутентификацию для своей учётной "
+#~ "записи.\n"
 #~ "      "
 
 #~ msgid ""
@@ -3428,7 +3628,8 @@ msgstr "Кто-то уже пригласил его/её."
 #~ msgstr ""
 #~ "\n"
 #~ "        Двухфакторная аутентификация для Вашей учётной записи\n"
-#~ "        не активирована. Для повышения безопасности учётной записи включите\n"
+#~ "        не активирована. Для повышения безопасности учётной записи "
+#~ "включите\n"
 #~ "        двухфакторную аутентификацию.\n"
 #~ "      "
 
@@ -3535,8 +3736,14 @@ msgstr "Кто-то уже пригласил его/её."
 #~ msgid "ICal Feeds"
 #~ msgstr "iCal-ленты"
 
-#~ msgid "These are URLs for different Calendar Feeds in the iCal (.ics) format. You can create as many as you want and import them in your calendar software."
-#~ msgstr "Здесь находятся ссылки на разные ленты календарей в формате iCal (.ics). Вы можете создать их столько, сколько будет необходимо и импортировать их в ПО для работы с календарями."
+#~ msgid ""
+#~ "These are URLs for different Calendar Feeds in the iCal (.ics) format. "
+#~ "You can create as many as you want and import them in your calendar "
+#~ "software."
+#~ msgstr ""
+#~ "Здесь находятся ссылки на разные ленты календарей в формате iCal (.ics). "
+#~ "Вы можете создать их столько, сколько будет необходимо и импортировать их "
+#~ "в ПО для работы с календарями."
 
 #~ msgid "Your iCal URLs"
 #~ msgstr "Ваши ссылки iCal"
@@ -3561,24 +3768,30 @@ msgstr "Кто-то уже пригласил его/её."
 
 #~ msgid ""
 #~ "\n"
-#~ "              This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used\n"
-#~ "              to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and\n"
+#~ "              This platform is powered by AlekSIS®, a web-based school "
+#~ "information system (SIS) which can be used\n"
+#~ "              to manage and/or publish organisational artifacts of "
+#~ "educational institutions. AlekSIS is free software and\n"
 #~ "              can be used by anyone.\n"
 #~ "            "
 #~ msgstr ""
 #~ "\n"
-#~ "              Эта платформа использует AlekSIS®, веб-инструмент информационной системы для обучения (SIS) при помощи которой\n"
-#~ "              можно управлять и/или публиковать оргинформацию учебных заведений. AlekSIS - бесплатное ПО\n"
+#~ "              Эта платформа использует AlekSIS®, веб-инструмент "
+#~ "информационной системы для обучения (SIS) при помощи которой\n"
+#~ "              можно управлять и/или публиковать оргинформацию учебных "
+#~ "заведений. AlekSIS - бесплатное ПО\n"
 #~ "              и его может использовать любой желающий.\n"
 #~ "            "
 
 #~ msgid ""
 #~ "\n"
-#~ "              AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V.\n"
+#~ "              AlekSIS® is a registered trademark of the AlekSIS open "
+#~ "source project, represented by Teckids e.V.\n"
 #~ "            "
 #~ msgstr ""
 #~ "\n"
-#~ "              AlekSIS® – зарегистрированная торговая марка проекта с открытым исходным кодом AlekSIS, которая представлена Teckids e.V.\n"
+#~ "              AlekSIS® – зарегистрированная торговая марка проекта с "
+#~ "открытым исходным кодом AlekSIS, которая представлена Teckids e.V.\n"
 #~ "            "
 
 #~ msgid "Website of AlekSIS"
@@ -3592,14 +3805,18 @@ msgstr "Кто-то уже пригласил его/её."
 
 #~ msgid ""
 #~ "\n"
-#~ "              The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence\n"
-#~ "              information from third-party apps, if installed, refer to the respective components below. The\n"
+#~ "              The core and the official apps of AlekSIS are licenced "
+#~ "under the EUPL, version 1.2 or later. For licence\n"
+#~ "              information from third-party apps, if installed, refer to "
+#~ "the respective components below. The\n"
 #~ "              licences are marked like this:\n"
 #~ "            "
 #~ msgstr ""
 #~ "\n"
-#~ "              Ядро и официальные дополнения AlekSIS лицензированы под лицензией EUPL, версии 1.2 или новее. Для получения информации\n"
-#~ "              о компонентах третих сторон, если таковы установлены, перейдите к соответствующим компонентам ниже.\n"
+#~ "              Ядро и официальные дополнения AlekSIS лицензированы под "
+#~ "лицензией EUPL, версии 1.2 или новее. Для получения информации\n"
+#~ "              о компонентах третих сторон, если таковы установлены, "
+#~ "перейдите к соответствующим компонентам ниже.\n"
 #~ "              Эти лицензии обозначены такой отметкой:\n"
 #~ "            "
 
@@ -3617,11 +3834,13 @@ msgstr "Кто-то уже пригласил его/её."
 
 #~ msgid ""
 #~ "\n"
-#~ "              Without activated JavaScript the progress status can't be updated.\n"
+#~ "              Without activated JavaScript the progress status can't be "
+#~ "updated.\n"
 #~ "            "
 #~ msgstr ""
 #~ "\n"
-#~ "              Без активного JavaScript статус выполнения обновляться не сможет.\n"
+#~ "              Без активного JavaScript статус выполнения обновляться не "
+#~ "сможет.\n"
 #~ "            "
 
 #~ msgid "Language"
@@ -3632,12 +3851,14 @@ msgstr "Кто-то уже пригласил его/её."
 
 #~ msgid ""
 #~ "\n"
-#~ "            Your administrator account is not linked to any person. Therefore,\n"
+#~ "            Your administrator account is not linked to any person. "
+#~ "Therefore,\n"
 #~ "            a dummy person has been linked to your account.\n"
 #~ "          "
 #~ msgstr ""
 #~ "\n"
-#~ "            Ваша административная учётная запись не соединена на с одним физлицом. Поэтому\n"
+#~ "            Ваша административная учётная запись не соединена на с одним "
+#~ "физлицом. Поэтому\n"
 #~ "            к Вашей учётной записи привязано фейковое физлицо.\n"
 #~ "          "
 
@@ -3650,8 +3871,10 @@ msgstr "Кто-то уже пригласил его/её."
 #~ msgstr ""
 #~ "\n"
 #~ "            Ваша учётная запись не связана с физлицом. Это значит,\n"
-#~ "            что у Вас нет доступа на к какой учебной информации. Обратитесь,\n"
-#~ "            пожалуйста, к администраторам AlekSIS в Вашем учебном заведении.\n"
+#~ "            что у Вас нет доступа на к какой учебной информации. "
+#~ "Обратитесь,\n"
+#~ "            пожалуйста, к администраторам AlekSIS в Вашем учебном "
+#~ "заведении.\n"
 #~ "          "
 
 #~ msgid "Impersonate"
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 66cac5f59cb9a328aa36be23f36f5dc481b8e1f1..07237fdac02af7750a2000f89167cb9bbc217a5c 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, PolymorphicQuerySet
 
 
-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,7 +139,7 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager):
         return super().get_queryset().filter(widget_id__in=dashboard_widget_pks)
 
 
-class PolymorphicCurrentSiteManager(CurrentSiteManagerWithoutMigrations, 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/0049_oauthapplication_post_logout_redirect_uris.py b/aleksis/core/migrations/0049_oauthapplication_post_logout_redirect_uris.py
new file mode 100644
index 0000000000000000000000000000000000000000..a14526b2f0c32077e873c9b3bcd0cb21ee9e0cd6
--- /dev/null
+++ b/aleksis/core/migrations/0049_oauthapplication_post_logout_redirect_uris.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.1.9 on 2023-06-17 10:59
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("core", "0048_delete_personalicalurl"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="oauthapplication",
+            name="post_logout_redirect_uris",
+            field=models.TextField(
+                blank=True, help_text="Allowed Post Logout URIs list, space separated"
+            ),
+        ),
+    ]
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 d1599b6b33b553dabcb668e41e00896ba352523c..dca6ba89ef488f3e32d9345badc6be238752e80d 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -30,13 +30,14 @@ from guardian.admin import GuardedModelAdmin
 from guardian.core import ObjectPermissionChecker
 from icalendar import Calendar
 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,
@@ -137,8 +138,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 = []
 
@@ -386,7 +395,7 @@ class ExtensiblePolymorphicModel(
     """Model class for extensible, polymorphic models."""
 
     objects = PolymorphicCurrentSiteManager()
-    objects_all_sites = PolymorphicManager()
+    objects_all = PolymorphicManager()
 
     class Meta:
         abstract = True
@@ -454,6 +463,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 249f5fd7e5382923d9ec6915c3ca666b74208eef..604e0d6cbb4b1f5c08c244c42ddc42b3e4084bbb 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -12,26 +12,42 @@ 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 .calendar import CalendarBaseType, SetCalendarStatusMutation
 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
@@ -47,6 +63,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)
@@ -71,6 +89,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)
+
     calendar = graphene.Field(CalendarBaseType)
 
     def resolve_ping(root, info, payload) -> str:
@@ -105,6 +128,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
 
@@ -175,7 +202,18 @@ class Query(graphene.ObjectType):
     def resolve_oauth_access_tokens(root, info, **kwargs):
         return OAuthAccessToken.objects.filter(user=info.context.user)
 
-    def resolve_calendar(self, info, **kwargs):
+    @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
+
+    @staticmethod
+    def resolve_calendar(root, info, **kwargs):
         return True
 
 
@@ -188,6 +226,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()
+
     set_calendar_status = SetCalendarStatusMutation.Field()
 
 
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/celery_progress.py b/aleksis/core/schema/celery_progress.py
index 5502b3317c47841c7ef02e9e5165db9ef37fe338..d03a8081d9560d0b5a4da6707c80f963fde6358d 100644
--- a/aleksis/core/schema/celery_progress.py
+++ b/aleksis/core/schema/celery_progress.py
@@ -88,7 +88,7 @@ class CeleryProgressFetchedMutation(graphene.Mutation):
     celery_progress = graphene.Field(CeleryProgressType)
 
     def mutate(root, info, task_id, **kwargs):
-        task = TaskUserAssignment.objects.filter(task_result__task_id=task_id)
+        task = TaskUserAssignment.objects.get(task_result__task_id=task_id)
 
         if not info.context.user.has_perm("core.view_progress_rule", task):
             return None
diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py
index 6eed201487348034b2d9b1cba616046b1794dbf2..90165cceb0fb33676e17aad5caf978ce7c124be5 100644
--- a/aleksis/core/schema/person.py
+++ b/aleksis/core/schema/person.py
@@ -55,14 +55,26 @@ class PersonType(DjangoObjectType):
     full_name = graphene.String()
     username = graphene.String()
     userid = graphene.ID()
-    photo = graphene.Field(FieldFileType)
-    avatar = graphene.Field(FieldFileType)
+    photo = graphene.Field(FieldFileType, required=False)
+    avatar = graphene.Field(FieldFileType, required=False)
     avatar_url = graphene.String()
     avatar_content_url = graphene.String()
-    secondary_image_url = graphene.String()
+    secondary_image_url = graphene.String(required=False)
+
+    street = graphene.String(required=False)
+    housenumber = graphene.String(required=False)
+    postal_code = graphene.String(required=False)
+    place = graphene.String(required=False)
+
+    phone_number = graphene.String(required=False)
+    mobile_number = graphene.String(required=False)
+    email = graphene.String(required=False)
+
+    date_of_birth = graphene.String(required=False)
+    place_of_birth = graphene.String(required=False)
 
     notifications = graphene.List(NotificationType)
-    unread_notifications_count = graphene.Int()
+    unread_notifications_count = graphene.Int(required=False)
 
     is_dummy = graphene.Boolean()
     preferences = graphene.Field(PersonPreferencesType)
@@ -150,7 +162,11 @@ class PersonType(DjangoObjectType):
         return root.user.id if root.user else None
 
     def resolve_unread_notifications_count(root, info, **kwargs):  # noqa
-        return root.unread_notifications_count
+        if root.pk and has_person(info.context) and root == info.context.user.person:
+            return root.unread_notifications_count
+        elif root.pk:
+            return 0
+        return None
 
     def resolve_photo(root, info, **kwargs):
         if info.context.user.has_perm("core.view_photo_rule", root):
@@ -199,11 +215,11 @@ class PersonType(DjangoObjectType):
         return root.is_dummy if hasattr(root, "is_dummy") else False
 
     def resolve_notifications(root: Person, info, **kwargs):
-        if has_person(info.context.user) and info.context.user.person == root:
+        if root.pk and has_person(info.context) and root == info.context.user.person:
             return root.notifications.filter(send_at__lte=timezone.now()).order_by(
                 "read", "-created"
             )
-        raise PermissionDenied()
+        return []
 
     def resolve_can_edit_person(root, info, **kwargs):  # noqa
         return info.context.user.has_perm("core.edit_person_rule", root)
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 e8bf327490dde0917b69851f796defdf60ded7c1..59c5c30efef72708a5081b7d354a1b7a6ce9330b 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -936,12 +936,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")
@@ -957,6 +964,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
 
@@ -1041,6 +1051,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/base_simple_print.html b/aleksis/core/templates/core/base_simple_print.html
index 6e66e28983679acebdd2df0993ae7b5fdf64e621..abb01712a5f52d4ec9a44e34a31b11b8a29f9ef1 100644
--- a/aleksis/core/templates/core/base_simple_print.html
+++ b/aleksis/core/templates/core/base_simple_print.html
@@ -4,6 +4,8 @@
 <!DOCTYPE html>
 <html lang="{{ LANGUAGE_CODE }}">
 <head>
+  <base href="{{ BASE_URL }}" />
+
   {% include "core/partials/meta.html" %}
 
   <title>
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 d959ad27a46ab662d9c63fddfa5bfd11fc4884b1..774e17ce5d0f221bed8fbc85b1c06a7d78511e8a 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 0889280858a64a61cd8857a0c6eef4f872012cfc..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."""
@@ -87,6 +94,10 @@ class AppConfig(django.apps.AppConfig):
         # Get string representation of licence in SPDX format
         licence = getattr(cls, "licence", None)
 
+        default_flags = {
+            "isFsfLibre": False,
+            "isOsiApproved": False,
+        }
         default_dict = {
             "isDeprecatedLicenseId": False,
             "isFsfLibre": False,
@@ -131,7 +142,7 @@ class AppConfig(django.apps.AppConfig):
             return (readable, flags, licence_dicts)
         else:
             # We could not find a valid licence
-            return ("Unknown", [default_dict])
+            return ("Unknown", default_flags, [default_dict])
 
     @classmethod
     def get_licence_dict(cls):
@@ -278,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
 
@@ -288,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 9d71ff52378b605b45be8bf686e378986d29b382..fa2404922d8a287c863151a6290718210a1c9bf6 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -93,7 +93,6 @@ from .forms import (
     OAuthApplicationForm,
     PersonForm,
     PersonPreferenceForm,
-    SchoolTermForm,
     SelectPermissionForm,
     SitePreferenceForm,
 )
@@ -115,7 +114,6 @@ from .models import (
     OAuthApplication,
     Person,
     PersonInvitation,
-    SchoolTerm,
 )
 from .registries import (
     group_preferences_registry,
@@ -132,7 +130,6 @@ from .tables import (
     GroupTypesTable,
     InvitationsTable,
     PersonsTable,
-    SchoolTermTable,
     UserGlobalPermissionTable,
     UserObjectPermissionTable,
 )
@@ -269,40 +266,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 bc408ab344bd9d1d22da7b562a97c02a07fdf91e..0d341f37bba0b64b50d9281e7d3fe2f93540256c 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -258,7 +258,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() },
@@ -272,7 +274,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 3287243f133810dce0b9a34d65d4c9fb2913a89f..4f14591b931a77ed87e0e2cc7af56b48805e820b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -25,13 +25,13 @@ django.setup()
 # -- Project information -----------------------------------------------------
 
 project = "AlekSIS-Core"
-copyright = "2019-2022 The AlekSIS team"
+copyright = "2019-2023 The AlekSIS team"
 author = "The AlekSIS Team"
 
 # The short X.Y version
-version = "3.0"
+version = "4.0"
 # The full version, including alpha/beta/rc tags
-release = "3.0.1.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 ffc7814b10c1d681a80e8df46a7e2b5ddaf6a144..9a66194ff91686a879f19c6eb2352032ccf704a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-Core"
-version = "3.0.1.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"
@@ -86,7 +86,7 @@ django-celery-beat = "^2.2.0"
 django-celery-email = "^3.0.0"
 django-jsonstore = "^0.5.0"
 django-polymorphic = "^3.0.0"
-django-colorfield = "^0.8.0"
+django-colorfield = "^0.9.0"
 django-bleach = "^3.0.0"
 django-guardian = "^2.2.0"
 rules = "^3.0"
@@ -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,9 +124,11 @@ 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"
 django-ical = "^1.8.3"
 django-recurrence = "^1.11.1"
 recurring-ical-events = "^2.0.2"
@@ -137,17 +139,54 @@ ldap = ["django-auth-ldap"]
 s3 = ["boto3", "django-storages"]
 sentry = ["sentry-sdk"]
 
-[tool.poetry.dev-dependencies]
-aleksis-builddeps = {version=">=2023.1.dev0", allow-prereleases=true}
-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-core"]
+requires = ["poetry-core>=1.0.0"]
 build-backend = "poetry.core.masonry.api"