diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index d4635a2169b2b0b5d6377614002d39532c926c15..50450c0854955fe4b8c6398f9bf5dda47f2f2cec 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,8 +9,27 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
-`3.1.1` - 2023-07-01
---------------------
+`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
 ~~~~~
@@ -165,13 +184,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`
@@ -198,8 +217,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
 ----------------------
@@ -350,9 +369,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.
@@ -556,11 +573,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
@@ -1183,3 +1198,4 @@ Fixed
 .. _3.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0
 .. _3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1
 .. _3.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.1
+.. _3.1.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.2
diff --git a/Dockerfile b/Dockerfile
index 2134ae81150a13dbab8282149a04df0fb8d20235..4a01dcc62c57fbcef4f56299f39f08d43675fb84 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -126,7 +126,7 @@ ONBUILD RUN set -e; \
                 eatmydata pip install $APPS; \
             fi; \
             eatmydata aleksis-admin vite build; \
-            eatmydata aleksis-admin collectstatic --no-input; \
+            eatmydata aleksis-admin collectstatic --no-input --clear; \
             rm -rf /usr/local/share/.cache; \
             eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \
             eatmydata apt-get autoremove --purge -y; \
diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
index 0105952fd08792c2cb4a472465d435a1f94a0eb3..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) !== "/"
@@ -103,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/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue
index 09b27197e5d62c5a5f15f7836d5b741925d21cc8..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.notifications.length > 0
+            unreadNotifications.length > 0
           "
         >
           mdi-bell-badge-outline
@@ -38,7 +38,7 @@
         v-if="
           myNotifications.person &&
           myNotifications.person.notifications &&
-          unreadNotifications.length
+          myNotifications.person.notifications.length
         "
       >
         <v-subheader>{{ $t("notifications.notifications") }}</v-subheader>
@@ -88,7 +88,9 @@ export default {
   },
   computed: {
     unreadNotifications() {
-      return this.myNotifications.filter((n) => !n.read);
+      return this.myNotifications.person.notifications
+        ? this.myNotifications.person.notifications.filter((n) => !n.read)
+        : [];
     },
   },
 };
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 1426b9c5ff244c31286e31542e690aeb0e8e3b77..b55a700db5390895c2c827a9ec13931bc55dbac9 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -27,7 +27,7 @@ from dynamic_preferences.types import FilePreference
 from guardian.admin import GuardedModelAdmin
 from guardian.core import ObjectPermissionChecker
 from jsonstore.fields import IntegerField, JSONFieldMixin
-from material.base import Layout, LayoutNode
+from material.base import Fieldset, Layout, LayoutNode
 from polymorphic.base import PolymorphicModelBase
 from polymorphic.managers import PolymorphicManager
 from polymorphic.models import PolymorphicModel
@@ -458,6 +458,20 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass):
         cls.base_layout.append(node)
         cls.layout = Layout(*cls.base_layout)
 
+        visit_nodes = [node]
+        while visit_nodes:
+            current_node = visit_nodes.pop()
+            if isinstance(current_node, Fieldset):
+                visit_nodes += node.elements
+            else:
+                field_name = (
+                    current_node if isinstance(current_node, str) else current_node.field_name
+                )
+                field = fields_for_model(cls._meta.model, [field_name])[field_name]
+                cls._meta.fields.append(field_name)
+                cls.base_fields[field_name] = field
+                setattr(cls, field_name, field)
+
 
 class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin):
     """A base class for ModelAdmin combining django-guardian and rules."""
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 9b7792d56f441b5b848e0fd1603036f3cbbcbf15..44790d7cf405402424ee51042efc7b98e2597936 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
 
diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py
index e370d6b125242381ab53f6c030c62d41c622d0ff..26f0752dc352cf0793ad7a9c7d70f95e015dd457 100644
--- a/aleksis/core/util/apps.py
+++ b/aleksis/core/util/apps.py
@@ -1,3 +1,4 @@
+import logging
 from importlib import metadata
 from typing import TYPE_CHECKING, Any, Optional, Sequence
 
@@ -26,8 +27,11 @@ class AppConfig(django.apps.AppConfig):
     def __init_subclass__(cls):
         super().__init_subclass__()
         cls.default = True
+        cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}")
 
     def ready(self):
+        self._logger.debug("Running app.ready")
+
         super().ready()
 
         # Register default listeners
@@ -36,9 +40,12 @@ class AppConfig(django.apps.AppConfig):
         preference_updated.connect(self.preference_updated)
         user_logged_in.connect(self.user_logged_in)
         user_logged_out.connect(self.user_logged_out)
+        self._logger.debug("Default signal handlers connected")
 
         # Getting an app ready means it should look at its config once
+        self._logger.debug("Force-loading preferences")
         self.preference_updated(self)
+        self._logger.debug("Preferences loaded")
 
     def get_distribution_name(self):
         """Get distribution name of application package."""
@@ -282,6 +289,8 @@ class AppConfig(django.apps.AppConfig):
         return {}
 
     def _maintain_default_data(self):
+        self._logger.debug("Maintaining default data for %s", self.get_name())
+
         from django.contrib.auth.models import Permission
         from django.contrib.contenttypes.models import ContentType
 
@@ -292,10 +301,19 @@ class AppConfig(django.apps.AppConfig):
         for model in self.get_models():
             if hasattr(model, "maintain_default_data"):
                 # Method implemented by each model object; can be left out
+                self._logger.info(
+                    "Maintaining default data of %s in %s", model._meta.model_name, self.get_name()
+                )
                 model.maintain_default_data()
             if hasattr(model, "extra_permissions"):
+                self._logger.info(
+                    "Maintaining extra permissions for %s in %s",
+                    model._meta.model_name,
+                    self.get_name(),
+                )
                 ct = ContentType.objects.get_for_model(model)
                 for perm, verbose_name in model.extra_permissions:
+                    self._logger.debug("Creating %s (%s)", perm, verbose_name)
                     Permission.objects.get_or_create(
                         codename=perm,
                         content_type=ct,
diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js
index 287534f096c186bc428de742c4e5ff56b71e8eab..415d12aa00f6b8a2bf2747e47d5cc3e32fd7aaa3 100644
--- a/aleksis/core/vite.config.js
+++ b/aleksis/core/vite.config.js
@@ -201,7 +201,9 @@ export default defineConfig({
         navigateFallback: "/",
         directoryIndex: null,
         navigateFallbackAllowlist: [
-          new RegExp("^/(?!(django|admin|graphql|__icons__))[^.]*$"),
+          new RegExp(
+            "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
+          ),
         ],
         additionalManifestEntries: [
           { url: "/", revision: crypto.randomUUID() },
@@ -215,7 +217,7 @@ export default defineConfig({
         runtimeCaching: [
           {
             urlPattern: new RegExp(
-              "^/(?!(django|admin|graphql|__icons__))[^.]*$"
+              "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$"
             ),
             handler: "CacheFirst",
           },
diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst
index 03ffc97b1e73b28561f2283338773672e4c41a73..b829cd2c92710efa6050f068319ef052e9b4138e 100644
--- a/docs/admin/10_install.rst
+++ b/docs/admin/10_install.rst
@@ -147,7 +147,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`:
 .. code-block:: shell
    pip3 install --break-system-packages aleksis
    aleksis-admin vite build
-   aleksis-admin collectstatic
+   aleksis-admin collectstatic --clear
    aleksis-admin migrate
    aleksis-admin createinitialrevisions
 
diff --git a/docs/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 468bf0a08826da58217865f095e3aee7b202d380..49d4a237181de7a821fe3eb5715178ae47cd3fd6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
@@ -128,24 +128,61 @@ graphene-django = ">=3.0.0, <=3.1.1"
 selenium = "^4.4.3"
 django-vite = "^2.0.2"
 graphene-django-cud = "^0.10.0"
+uwsgi = "^2.0.21"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]
 s3 = ["boto3", "django-storages"]
 sentry = ["sentry-sdk"]
 
-[tool.poetry.dev-dependencies]
-aleksis-builddeps = {version=">=2023.1.dev0", allow-prereleases=true}
-selenium = "<4.10.0"
-uwsgi = "^2.0"
-
 [tool.poetry.scripts]
 aleksis-admin = 'aleksis.core.__main__:aleksis_cmd'
 
+[tool.poetry.group.dev.dependencies]
+django-stubs = "^4.2"
+
+safety = "^2.3.5"
+
+flake8 = "^6.0.0"
+flake8-django = "^1.0.0"
+flake8-fixme = "^1.1.1"
+flake8-mypy = "^17.8.0"
+flake8-bandit = "^4.1.1"
+flake8-builtins = "^2.0.0"
+flake8-docstrings = "^1.5.0"
+flake8-rst-docstrings = "^0.3.0"
+
+black = ">=21.0"
+flake8-black = "^0.3.0"
+
+isort = "^5.0.0"
+flake8-isort = "^6.0.0"
+
+curlylint = "^0.13.0"
+
+[tool.poetry.group.test.dependencies]
+pytest = "^7.2"
+pytest-django = "^4.1"
+pytest-django-testing-postgresql = "^0.2"
+pytest-cov = "^4.0.0"
+pytest-sugar = "^0.9.2"
+selenium = "<4.10.0"
+freezegun = "^1.1.0"
+
+[tool.poetry.group.docs]
+optional = true
+
+[tool.poetry.group.docs.dependencies]
+sphinx = "^7.0"
+sphinxcontrib-django = "^2.3.0"
+sphinxcontrib-svg2pdfconverter = "^1.1.1"
+sphinx-autodoc-typehints = "^1.7"
+sphinx_material = "^0.0.35"
+
 [tool.black]
 line-length = 100
 exclude = "/migrations/"
 
 [build-system]
-requires = ["poetry>=1.0"]
-build-backend = "poetry.masonry.api"
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"