diff --git a/.dev-js/package.json b/.dev-js/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..f1a3b8b143115ffb8f0fee2d452ec2830cd888a3
--- /dev/null
+++ b/.dev-js/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "aleksis-builddeps",
+  "version": "1.0.0",
+  "dependencies": {
+    "@intlify/eslint-plugin-vue-i18n": "^2.0.0",
+    "eslint": "^8.26.0",
+    "eslint-config-prettier": "^9.0.0",
+    "eslint-plugin-vue": "^9.7.0",
+    "prettier": "^3.0.0",
+    "stylelint": "^15.0.0",
+    "stylelint-config-prettier": "^9.0.3",
+    "stylelint-config-standard": "^34.0.0"
+  }
+}
diff --git a/.gitignore b/.gitignore
index 031b076060cbc08961e8996eaeb61c394a46efaf..79b5b76de6f6445254cbf64f7e4fb228ffdf0ab8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,29 +1,29 @@
 # Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
 *$py.class
+*.py[cod]
+__pycache__/
 
 # Distribution / packaging
+*.egg
+*.egg-info/
 .Python
+.eggs/
+.installed.cfg
 build/
 develop-eggs/
 dist/
 downloads/
 eggs/
-.eggs/
 lib/
 lib64/
 parts/
 sdist/
 var/
 wheels/
-*.egg-info/
-.installed.cfg
-*.egg
 
 # Installer logs
-pip-log.txt
 pip-delete-this-directory.txt
+pip-log.txt
 
 # Translations
 *.mo
@@ -39,22 +39,56 @@ local_settings.py
 # Environments
 .env
 .venv
+ENV/
 env/
 venv/
-ENV/
 
 # Editors
 *~
 DEADJOE
 \#*#
 
+# IntelliJ
+.idea
+.idea/
+
+# VSCode
+.vscode/
+.history/
+*.code-workspace
+
 # Database
 db.sqlite3
 
 # Sphinx
 docs/_build/
 
-# Test
-.tox/
+# TeX
+*.aux
+
+# Generated files
+/cache
+/node_modules
+.dev-js/node_modules
+/static/
+/whoosh_index/
+.vite
+.dev-js/.yarn
+.dev-js/.pnp.cjs
+.dev-js/.pnp.loader.mjs
 
+# Lock files
 poetry.lock
+package-lock.json
+yarn.lock
+.dev-js/yarn.lock
+
+# Tests
+.coverage
+.mypy_cache/
+.tox/
+htmlcov/
+
+# Data
+maintenance_mode_state.txt
+media/
diff --git a/.prettierignore b/.prettierignore
index 38d141b743fd55678f50077c0617924475817095..de783fc60a1ca5f6e25204bb7a51eab815bc77ce 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -89,3 +89,6 @@ yarn.lock
 # Do not check/reformat generated files
 aleksis/core/util/licenses.json
 .vite/
+
+.pnp.cjs
+.pnp.loader.mjs
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index c4910d94b5b1e282a9bb6b1ede5c930df33d3c6c..643ff17db9651a866c10dedddd59f1feb9d6f84f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,9 @@ and this project adheres to `Semantic Versioning`_.
 Unreleased
 ----------
 
+`3.0.2`_ - 2023-09-10
+---------------------
+
 Fixed
 ~~~~~
 
@@ -401,4 +404,4 @@ Fixed
 .. _3.0b0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/3.0b0
 .. _3.0: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/3.0
 .. _3.0.1: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/3.0.1
-
+.. _3.0.2: https://edugit.org/AlekSIS/Official/AlekSIS-App-Chronos/-/tags/3.0.2
diff --git a/aleksis/apps/chronos/filters.py b/aleksis/apps/chronos/filters.py
index d868fdf59cdd0850f4f623db13da424e79819be4..5e231ef98ed73c3102288a09d8acabb7a39d0972 100644
--- a/aleksis/apps/chronos/filters.py
+++ b/aleksis/apps/chronos/filters.py
@@ -1,4 +1,4 @@
-from typing import Sequence
+from collections.abc import Sequence
 
 from django.db.models import Count, Q
 from django.forms import RadioSelect
diff --git a/aleksis/apps/chronos/form_extensions.py b/aleksis/apps/chronos/form_extensions.py
deleted file mode 100644
index 9cb3685319279e96401f373f97fa9e322fd0885c..0000000000000000000000000000000000000000
--- a/aleksis/apps/chronos/form_extensions.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from django.utils.translation import gettext as _
-
-from material import Fieldset
-
-from aleksis.core.forms import AnnouncementForm, EditGroupForm
-
-AnnouncementForm.add_node_to_layout(Fieldset(_("Options for timetables"), "show_in_timetables"))
-EditGroupForm.add_node_to_layout(Fieldset(_("Optional data for timetables"), "subject_id"))
diff --git a/aleksis/apps/chronos/frontend/components/SelectTimetable.vue b/aleksis/apps/chronos/frontend/components/SelectTimetable.vue
index fb98d1f56c51d4261502df5d8ec2889e39e6445c..4f880bdc2bf7952017f7c92cd149e1cd5ad23c27 100644
--- a/aleksis/apps/chronos/frontend/components/SelectTimetable.vue
+++ b/aleksis/apps/chronos/frontend/components/SelectTimetable.vue
@@ -68,7 +68,7 @@ export default {
           class="flex-grow-1"
           :value="type.id"
         >
-          {{ type.name }}
+          {{ $t(type.name) }}
         </v-btn>
       </v-btn-toggle>
     </v-card-text>
diff --git a/aleksis/apps/chronos/frontend/components/Timetable.vue b/aleksis/apps/chronos/frontend/components/Timetable.vue
index e4fca5dfb63f26597cd534315fed8f32b9b8a2de..83b13708fb6a2ed38c9cd3ee7a8c34f129e1ffee 100644
--- a/aleksis/apps/chronos/frontend/components/Timetable.vue
+++ b/aleksis/apps/chronos/frontend/components/Timetable.vue
@@ -20,8 +20,8 @@ export default {
             this.availableTimetables.find(
               (t) =>
                 t.objId === this.$route.params.id &&
-                t.type.toLowerCase() === this.$route.params.type
-            )
+                t.type.toLowerCase() === this.$route.params.type,
+            ),
           );
         }
       },
@@ -59,7 +59,7 @@ export default {
   methods: {
     findNextTimetable(offset = 1) {
       const currentIndex = this.availableTimetablesIds.indexOf(
-        this.selected.id
+        this.selected.id,
       );
       const newIndex = currentIndex + offset;
       if (newIndex < 0 || newIndex >= this.availableTimetablesIds.length) {
diff --git a/aleksis/apps/chronos/frontend/components/timetableTypes.js b/aleksis/apps/chronos/frontend/components/timetableTypes.js
index 692854f18411cc2f3ed1abf35069d28c9b18e51c..2aa2ae17decbed87abea83e54627a9067db1d712 100644
--- a/aleksis/apps/chronos/frontend/components/timetableTypes.js
+++ b/aleksis/apps/chronos/frontend/components/timetableTypes.js
@@ -1,13 +1,13 @@
 export default {
   GROUP: {
-    name: "Groups",
+    name: "chronos.timetable.types.groups",
     id: "GROUP",
     icon: "mdi-account-group-outline",
   },
   TEACHER: {
-    name: "Teachers",
+    name: "chronos.timetable.types.teachers",
     id: "TEACHER",
     icon: "mdi-account-outline",
   },
-  ROOM: { name: "Rooms", id: "ROOM", icon: "mdi-door" },
+  ROOM: { name: "chronos.timetable.types.rooms", id: "ROOM", icon: "mdi-door" },
 };
diff --git a/aleksis/apps/chronos/frontend/index.js b/aleksis/apps/chronos/frontend/index.js
index 30451f93c2f0dfcb769a4ab92385b50fe1bfe61c..a6a93183bca2cd7e07a5bc5a86a16a9c12e80938 100644
--- a/aleksis/apps/chronos/frontend/index.js
+++ b/aleksis/apps/chronos/frontend/index.js
@@ -6,6 +6,7 @@ export default {
     inMenu: true,
     titleKey: "chronos.menu_title",
     icon: "mdi-school-outline",
+    iconActive: "mdi-school",
     validators: [hasPersonValidator],
   },
   props: {
diff --git a/aleksis/apps/chronos/frontend/messages/de.json b/aleksis/apps/chronos/frontend/messages/de.json
index 512498ccfae594033f7634e09bfba992233612fe..86148a7bac93fe2dfbd36568994a2c1af35e8dbe 100644
--- a/aleksis/apps/chronos/frontend/messages/de.json
+++ b/aleksis/apps/chronos/frontend/messages/de.json
@@ -12,7 +12,12 @@
       "search": "Stundenpläne suchen",
       "prev": "Vorheriger Stundenplan",
       "next": "Nächster Stundenplan",
-      "select": "Stundenplan auswählen"
+      "select": "Stundenplan auswählen",
+      "types": {
+        "groups": "Gruppen",
+        "teachers": "Lehrkräfte",
+        "rooms": "Räume"
+      }
     },
     "lessons": {
       "menu_title_daily": "Tagesstunden"
diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json
index b995b749a1677cdfbdbfef9888f4bd427062717c..ee23c28f02b5b872222461653ea1e624eff54c5b 100644
--- a/aleksis/apps/chronos/frontend/messages/en.json
+++ b/aleksis/apps/chronos/frontend/messages/en.json
@@ -12,7 +12,12 @@
       "search": "Search Timetables",
       "prev": "Previous Timetable",
       "next": "Next Timetable",
-      "select": "Select Timetable"
+      "select": "Select Timetable",
+      "types": {
+        "groups": "Groups",
+        "teachers": "Teachers",
+        "rooms": "Rooms"
+      }
     },
     "lessons": {
       "menu_title_daily": "Daily lessons"
diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py
index 79bd043f4b28300ca5d6df961d43e820ae92a40b..fae25d1817462aa3f0fd0babf619bf480cb65320 100644
--- a/aleksis/apps/chronos/managers.py
+++ b/aleksis/apps/chronos/managers.py
@@ -1,8 +1,8 @@
+from collections.abc import Iterable
 from datetime import date, datetime, timedelta
 from enum import Enum
-from typing import Dict, Iterable, List, Optional, Union
+from typing import TYPE_CHECKING, Optional, Union
 
-from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager
 from django.db import models
 from django.db.models import ExpressionWrapper, F, Func, Q, QuerySet, Value
 from django.db.models.fields import DateField
@@ -12,10 +12,18 @@ from calendarweek import CalendarWeek
 from polymorphic.managers import PolymorphicQuerySet
 
 from aleksis.apps.chronos.util.date import week_weekday_from_date, week_weekday_to_date
-from aleksis.core.managers import DateRangeQuerySetMixin, SchoolTermRelatedQuerySet
+from aleksis.apps.cursus.models import Course
+from aleksis.core.managers import (
+    AlekSISBaseManagerWithoutMigrations,
+    DateRangeQuerySetMixin,
+    SchoolTermRelatedQuerySet,
+)
 from aleksis.core.models import Group, Person, Room
 from aleksis.core.util.core_helpers import get_site_preferences
 
+if TYPE_CHECKING:
+    from .models import Holiday, LessonPeriod, ValidityRange
+
 
 class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
     """Custom query set for validity ranges."""
@@ -66,10 +74,6 @@ class ValidityRangeRelatedQuerySet(QuerySet):
             return None
 
 
-class CurrentSiteManager(_CurrentSiteManager):
-    use_in_migrations = False
-
-
 class TimetableType(Enum):
     """Enum for different types of timetables."""
 
@@ -82,7 +86,7 @@ class TimetableType(Enum):
         return cls.__members__.get(s.upper())
 
 
-class LessonPeriodManager(CurrentSiteManager):
+class LessonPeriodManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to lesson periods."""
 
     def get_queryset(self):
@@ -107,7 +111,7 @@ class LessonPeriodManager(CurrentSiteManager):
         )
 
 
-class LessonSubstitutionManager(CurrentSiteManager):
+class LessonSubstitutionManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to lesson substitutions."""
 
     def get_queryset(self):
@@ -133,7 +137,7 @@ class LessonSubstitutionManager(CurrentSiteManager):
         )
 
 
-class SupervisionManager(CurrentSiteManager):
+class SupervisionManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to supervisions."""
 
     def get_queryset(self):
@@ -151,7 +155,7 @@ class SupervisionManager(CurrentSiteManager):
         )
 
 
-class SupervisionSubstitutionManager(CurrentSiteManager):
+class SupervisionSubstitutionManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to supervision substitutions."""
 
     def get_queryset(self):
@@ -171,7 +175,7 @@ class SupervisionSubstitutionManager(CurrentSiteManager):
         )
 
 
-class EventManager(CurrentSiteManager):
+class EventManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to events."""
 
     def get_queryset(self):
@@ -190,7 +194,7 @@ class EventManager(CurrentSiteManager):
         )
 
 
-class ExtraLessonManager(CurrentSiteManager):
+class ExtraLessonManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to extra lessons."""
 
     def get_queryset(self):
@@ -208,7 +212,7 @@ class ExtraLessonManager(CurrentSiteManager):
         )
 
 
-class BreakManager(CurrentSiteManager):
+class BreakManager(AlekSISBaseManagerWithoutMigrations):
     """Manager adding specific methods to breaks."""
 
     def get_queryset(self):
@@ -403,7 +407,7 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
 
         return lesson_periods
 
-    def group_by_validity(self) -> Dict["ValidityRange", List["LessonPeriod"]]:
+    def group_by_validity(self) -> dict["ValidityRange", list["LessonPeriod"]]:
         """Group lesson periods by validity range as dictionary."""
         lesson_periods_by_validity = {}
         for lesson_period in self:
@@ -636,7 +640,7 @@ class AbsenceQuerySet(DateRangeQuerySetMixin, SchoolTermRelatedQuerySet):
 class HolidayQuerySet(QuerySet, DateRangeQuerySetMixin):
     """QuerySet with custom query methods for holidays."""
 
-    def get_all_days(self) -> List[date]:
+    def get_all_days(self) -> list[date]:
         """Get all days included in the selected holidays."""
         holiday_days = []
         for holiday in self:
@@ -885,6 +889,12 @@ class LessonEventQuerySet(PolymorphicQuerySet):
         )
         return self.filter(Q(rooms=room) | Q(pk__in=amended)).distinct()
 
+    def for_course(self, course: Union[int, Course]):
+        amended = self.filter(Q(amended_by__isnull=False) & (Q(course=course))).values_list(
+            "amended_by__pk", flat=True
+        )
+        return self.filter(Q(course=course) | Q(pk__in=amended)).distinct()
+
     def for_person(self, person: Union[int, Person]):
         amended = self.filter(
             Q(amended_by__isnull=False) & (Q(teachers=person) | Q(groups__members=person))
@@ -892,3 +902,18 @@ class LessonEventQuerySet(PolymorphicQuerySet):
         return self.filter(
             Q(teachers=person) | Q(groups__members=person) | Q(pk__in=amended)
         ).distinct()
+
+    def related_to_person(self, person: Union[int, Person]):
+        amended = self.filter(
+            Q(amended_by__isnull=False)
+            & (Q(teachers=person) | Q(groups__members=person) | Q(groups__owners=person))
+        ).values_list("amended_by__pk", flat=True)
+        return self.filter(
+            Q(teachers=person) | Q(groups__members=person) | Q(groups__owners=person)
+        ).distinct()
+
+    def not_amended(self):
+        return self.filter(amended_by__isnull=True)
+
+    def not_amending(self):
+        return self.filter(amends__isnull=True)
diff --git a/aleksis/apps/chronos/migrations/0001_initial.py b/aleksis/apps/chronos/migrations/0001_initial.py
index 023ea06eb613a00e8b24b0f90046d92ae906a126..758f099cade786ea7cbab3cb6a4616847fa2c2d6 100644
--- a/aleksis/apps/chronos/migrations/0001_initial.py
+++ b/aleksis/apps/chronos/migrations/0001_initial.py
@@ -18,7 +18,6 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ("core", "0001_initial"),
-        ("sites", "0002_alter_domain_unique"),
     ]
 
     operations = [
@@ -237,15 +236,6 @@ class Migration(migrations.Migration):
                 ),
                 ("time_start", models.TimeField(verbose_name="Start time")),
                 ("time_end", models.TimeField(verbose_name="End time")),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Time period",
@@ -273,15 +263,6 @@ class Migration(migrations.Migration):
                     ),
                 ),
                 ("date", models.DateField(verbose_name="Date")),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
                 (
                     "supervision",
                     models.ForeignKey(
@@ -339,15 +320,6 @@ class Migration(migrations.Migration):
                     "colour_bg",
                     colorfield.fields.ColorField(default="#FFFFFF", max_length=18),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Supervision area",
@@ -376,16 +348,6 @@ class Migration(migrations.Migration):
                 verbose_name="Break",
             ),
         ),
-        migrations.AddField(
-            model_name="supervision",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="sites.Site",
-            ),
-        ),
         migrations.AddField(
             model_name="supervision",
             name="teacher",
@@ -444,15 +406,6 @@ class Migration(migrations.Migration):
                         verbose_name="Background colour",
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Subject",
@@ -486,15 +439,6 @@ class Migration(migrations.Migration):
                     ),
                 ),
                 ("name", models.CharField(max_length=255, verbose_name="Long name")),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Room",
@@ -561,15 +505,6 @@ class Migration(migrations.Migration):
                         verbose_name="Room",
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
                 (
                     "subject",
                     models.ForeignKey(
@@ -623,16 +558,6 @@ class Migration(migrations.Migration):
                 verbose_name="Room",
             ),
         ),
-        migrations.AddField(
-            model_name="lessonperiod",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="sites.Site",
-            ),
-        ),
         migrations.AddField(
             model_name="lesson",
             name="periods",
@@ -643,16 +568,6 @@ class Migration(migrations.Migration):
                 verbose_name="Periods",
             ),
         ),
-        migrations.AddField(
-            model_name="lesson",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="sites.Site",
-            ),
-        ),
         migrations.AddField(
             model_name="lesson",
             name="subject",
@@ -697,15 +612,6 @@ class Migration(migrations.Migration):
                     "comments",
                     models.TextField(blank=True, null=True, verbose_name="Comments"),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Holiday",
@@ -771,15 +677,6 @@ class Migration(migrations.Migration):
                         verbose_name="Room",
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
                 (
                     "subject",
                     models.ForeignKey(
@@ -857,15 +754,6 @@ class Migration(migrations.Migration):
                         verbose_name="End period",
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Exam",
@@ -930,15 +818,6 @@ class Migration(migrations.Migration):
                         related_name="events", to="chronos.Room", verbose_name="Rooms"
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
                 (
                     "teachers",
                     models.ManyToManyField(
@@ -981,16 +860,6 @@ class Migration(migrations.Migration):
                 verbose_name="Time period before break ends",
             ),
         ),
-        migrations.AddField(
-            model_name="break",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="sites.Site",
-            ),
-        ),
         migrations.CreateModel(
             name="AbsenceReason",
             fields=[
@@ -1019,15 +888,6 @@ class Migration(migrations.Migration):
                         blank=True, max_length=255, null=True, verbose_name="Name"
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
             ],
             options={
                 "verbose_name": "Absence reason",
@@ -1112,15 +972,6 @@ class Migration(migrations.Migration):
                         verbose_name="Room",
                     ),
                 ),
-                (
-                    "site",
-                    models.ForeignKey(
-                        default=1,
-                        editable=False,
-                        on_delete=django.db.models.deletion.CASCADE,
-                        to="sites.Site",
-                    ),
-                ),
                 (
                     "teacher",
                     models.ForeignKey(
diff --git a/aleksis/apps/chronos/migrations/0002_school_term_validity.py b/aleksis/apps/chronos/migrations/0002_school_term_validity.py
index 947ccad0a618cc00f2697d17c9d4f8e4d60b0d65..9bab775a78f46c6cdfc62138d8bcc4f9d37ce405 100644
--- a/aleksis/apps/chronos/migrations/0002_school_term_validity.py
+++ b/aleksis/apps/chronos/migrations/0002_school_term_validity.py
@@ -43,7 +43,6 @@ def migrate_lesson(apps, schema_editor):
 class Migration(migrations.Migration):
     dependencies = [
         ("core", "0002_school_term"),
-        ("sites", "0002_alter_domain_unique"),
         ("chronos", "0001_initial"),
     ]
 
@@ -186,16 +185,6 @@ class Migration(migrations.Migration):
                 verbose_name="School term",
             ),
         ),
-        migrations.AddField(
-            model_name="validityrange",
-            name="site",
-            field=models.ForeignKey(
-                default=1,
-                editable=False,
-                on_delete=django.db.models.deletion.CASCADE,
-                to="sites.Site",
-            ),
-        ),
         migrations.AddField(
             model_name="lesson",
             name="validity",
diff --git a/aleksis/apps/chronos/migrations/0008_unique_constraints.py b/aleksis/apps/chronos/migrations/0008_unique_constraints.py
index df88345e151019bdc5830323aefb13c2e8cf2716..a009d458ab31a172ec3a24eb0e52f6d1f25f57fb 100644
--- a/aleksis/apps/chronos/migrations/0008_unique_constraints.py
+++ b/aleksis/apps/chronos/migrations/0008_unique_constraints.py
@@ -48,7 +48,7 @@ class Migration(migrations.Migration):
         ),
         migrations.AddConstraint(
             model_name='absencereason',
-            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_short_name_per_site_absence_reason'),
+            constraint=models.UniqueConstraint(fields=('short_name',), name='unique_short_name_per_site_absence_reason'),
         ),
         migrations.AddConstraint(
             model_name='break',
@@ -60,19 +60,19 @@ class Migration(migrations.Migration):
         ),
         migrations.AddConstraint(
             model_name='room',
-            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_short_name_per_site_room'),
+            constraint=models.UniqueConstraint(fields=('short_name',), name='unique_short_name_per_site_room'),
         ),
         migrations.AddConstraint(
             model_name='subject',
-            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_short_name_per_site_subject'),
+            constraint=models.UniqueConstraint(fields=('short_name',), name='unique_short_name_per_site_subject'),
         ),
         migrations.AddConstraint(
             model_name='subject',
-            constraint=models.UniqueConstraint(fields=('site_id', 'name'), name='unique_name_per_site'),
+            constraint=models.UniqueConstraint(fields=('name',), name='unique_name_per_site'),
         ),
         migrations.AddConstraint(
             model_name='supervisionarea',
-            constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_short_name_per_site_supervision_area'),
+            constraint=models.UniqueConstraint(fields=('short_name',), name='unique_short_name_per_site_supervision_area'),
         ),
         migrations.AddConstraint(
             model_name='timeperiod',
diff --git a/aleksis/apps/chronos/migrations/0009_automaticplan.py b/aleksis/apps/chronos/migrations/0009_automaticplan.py
index 1bcc9f3c28c5d832914c7b5c15993a1bbee85208..f35a6789cf5dc0db69cdb2e902b81c3e5b6a6538 100644
--- a/aleksis/apps/chronos/migrations/0009_automaticplan.py
+++ b/aleksis/apps/chronos/migrations/0009_automaticplan.py
@@ -29,7 +29,6 @@ class Migration(migrations.Migration):
             },
             bases=('resint.livedocument',),
             managers=[
-                ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()),
             ],
         ),
     ]
diff --git a/aleksis/apps/chronos/migrations/0015_add_managed_by_app_label.py b/aleksis/apps/chronos/migrations/0014_add_managed_by_app_label.py
similarity index 98%
rename from aleksis/apps/chronos/migrations/0015_add_managed_by_app_label.py
rename to aleksis/apps/chronos/migrations/0014_add_managed_by_app_label.py
index d4906fcd7eefb3fffd36136c75fa8d4feee76cc8..6b38675ef2d294bf221b7e0f9a5d46d37c03a278 100644
--- a/aleksis/apps/chronos/migrations/0015_add_managed_by_app_label.py
+++ b/aleksis/apps/chronos/migrations/0014_add_managed_by_app_label.py
@@ -8,8 +8,7 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('sites', '0002_alter_domain_unique'),
-        ('chronos', '0014_lessonevent'),
+        ('chronos', '0013_move_room_to_core'),
     ]
 
     operations = [
diff --git a/aleksis/apps/chronos/migrations/0015_drop_site.py b/aleksis/apps/chronos/migrations/0015_drop_site.py
new file mode 100644
index 0000000000000000000000000000000000000000..708dee3b879fe81afb3afe2be3fc0bfd69bec478
--- /dev/null
+++ b/aleksis/apps/chronos/migrations/0015_drop_site.py
@@ -0,0 +1,149 @@
+# Generated by Django 4.2.9 on 2024-01-09 15:08
+
+import colorfield.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('chronos', '0014_add_managed_by_app_label'),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name='absencereason',
+            managers=[
+            ],
+        ),
+        migrations.AlterModelManagers(
+            name='subject',
+            managers=[
+            ],
+        ),
+        migrations.AlterModelManagers(
+            name='supervisionarea',
+            managers=[
+            ],
+        ),
+        migrations.RemoveConstraint(
+            model_name='absencereason',
+            name='unique_short_name_per_site_absence_reason',
+        ),
+        migrations.RemoveConstraint(
+            model_name='break',
+            name='unique_short_name_per_site_break',
+        ),
+        migrations.RemoveConstraint(
+            model_name='subject',
+            name='unique_short_name_per_site_subject',
+        ),
+        migrations.RemoveConstraint(
+            model_name='supervisionarea',
+            name='unique_short_name_per_site_supervision_area',
+        ),
+    ] + [
+        migrations.RunSQL(
+            f"ALTER TABLE chronos_{model_name} drop column if exists site_id;"
+        ) for model_name  in
+        [
+            "absence",
+            "absencereason",
+            "automaticplan",
+            "break",
+            "event",
+            "exam",
+            "extralesson",
+            "holiday",
+            "lesson",
+            "lessonperiod",
+            "lessonsubstitution",
+            "subject",
+            "supervision",
+            "supervisionarea",
+            "supervisionsubstitution",
+            "timeperiod",
+            "validityrange"
+        ]
+    ] +  [
+        migrations.AlterField(
+            model_name='absence',
+            name='comment',
+            field=models.TextField(blank=True, default='', verbose_name='Comment'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='absencereason',
+            name='name',
+            field=models.CharField(blank=True, default='', max_length=255, verbose_name='Name'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='absencereason',
+            name='short_name',
+            field=models.CharField(max_length=255, unique=True, verbose_name='Short name'),
+        ),
+        migrations.AlterField(
+            model_name='event',
+            name='title',
+            field=models.CharField(blank=True, default='', max_length=255, verbose_name='Title'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='extralesson',
+            name='comment',
+            field=models.CharField(blank=True, default='', max_length=255, verbose_name='Comment'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='holiday',
+            name='comments',
+            field=models.TextField(blank=True, default='', verbose_name='Comments'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='lessonsubstitution',
+            name='comment',
+            field=models.TextField(blank=True, default='', verbose_name='Comment'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='subject',
+            name='colour_bg',
+            field=colorfield.fields.ColorField(blank=True, default='', image_field=None, max_length=25, samples=None, verbose_name='Background colour'),
+        ),
+        migrations.AlterField(
+            model_name='subject',
+            name='colour_fg',
+            field=colorfield.fields.ColorField(blank=True, default='', image_field=None, max_length=25, samples=None, verbose_name='Foreground colour'),
+        ),
+        migrations.AlterField(
+            model_name='subject',
+            name='short_name',
+            field=models.CharField(max_length=255, unique=True, verbose_name='Short name'),
+        ),
+        migrations.AlterField(
+            model_name='supervisionarea',
+            name='colour_bg',
+            field=colorfield.fields.ColorField(default='#FFFFFF', image_field=None, max_length=25, samples=None),
+        ),
+        migrations.AlterField(
+            model_name='supervisionarea',
+            name='colour_fg',
+            field=colorfield.fields.ColorField(default='#000000', image_field=None, max_length=25, samples=None),
+        ),
+        migrations.AlterField(
+            model_name='supervisionarea',
+            name='short_name',
+            field=models.CharField(max_length=255, unique=True, verbose_name='Short name'),
+        ),
+        migrations.AlterField(
+            model_name='timeperiod',
+            name='weekday',
+            field=models.PositiveSmallIntegerField(choices=[(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday')], verbose_name='Week day'),
+        ),
+        migrations.AddConstraint(
+            model_name='break',
+            constraint=models.UniqueConstraint(fields=('validity', 'short_name'), name='unique_short_name_per_validity_break'),
+        ),
+    ]
diff --git a/aleksis/apps/chronos/migrations/0014_lessonevent.py b/aleksis/apps/chronos/migrations/0016_lessonevent.py
similarity index 97%
rename from aleksis/apps/chronos/migrations/0014_lessonevent.py
rename to aleksis/apps/chronos/migrations/0016_lessonevent.py
index 7b0e0cbac8478b850d847d067591b6885d606d2a..a89abc8906ff6cec9672d0e2a9b46aac39ed80aa 100644
--- a/aleksis/apps/chronos/migrations/0014_lessonevent.py
+++ b/aleksis/apps/chronos/migrations/0016_lessonevent.py
@@ -6,10 +6,9 @@ import django.db.models.deletion
 
 class Migration(migrations.Migration):
     dependencies = [
-        ("sites", "0002_alter_domain_unique"),
         ("core", "0051_calendarevent_and_holiday"),
         ("cursus", "0001_initial"),
-        ("chronos", "0013_move_room_to_core"),
+        ("chronos", "0015_drop_site"),
     ]
 
     operations = [
diff --git a/aleksis/apps/chronos/mixins.py b/aleksis/apps/chronos/mixins.py
index 5a784579b4ca7a1adbbc32ec2c54f44cc28f18b0..8cb4dab29467236f950b5b9ff56f5fa7ab7da435 100644
--- a/aleksis/apps/chronos/mixins.py
+++ b/aleksis/apps/chronos/mixins.py
@@ -7,7 +7,7 @@ from django.utils.translation import gettext as _
 from calendarweek import CalendarWeek
 
 from aleksis.apps.chronos.util.date import week_weekday_to_date
-from aleksis.core.managers import CurrentSiteManagerWithoutMigrations
+from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations
 from aleksis.core.mixins import ExtensibleModel
 
 from .managers import ValidityRangeRelatedQuerySet
@@ -16,7 +16,7 @@ from .managers import ValidityRangeRelatedQuerySet
 class ValidityRangeRelatedExtensibleModel(ExtensibleModel):
     """Add relation to validity range."""
 
-    objects = CurrentSiteManagerWithoutMigrations.from_queryset(ValidityRangeRelatedQuerySet)()
+    objects = AlekSISBaseManagerWithoutMigrations.from_queryset(ValidityRangeRelatedQuerySet)()
 
     validity = models.ForeignKey(
         "chronos.ValidityRange",
diff --git a/aleksis/apps/chronos/model_extensions.py b/aleksis/apps/chronos/model_extensions.py
index 1cbe2eca074d4e42c5ac9e558999191c652787f8..8f43350ee5a5ab019bad41f9674737e414c9d2f3 100644
--- a/aleksis/apps/chronos/model_extensions.py
+++ b/aleksis/apps/chronos/model_extensions.py
@@ -4,14 +4,13 @@ from typing import Optional, Union
 from django.dispatch import receiver
 from django.utils.translation import gettext_lazy as _
 
-from jsonstore import BooleanField
 from reversion.models import Revision
 
 from aleksis.core.models import Announcement, Group, Person
 from aleksis.core.util.core_helpers import get_site_preferences
 
 from .managers import TimetableType
-from .models import Lesson, LessonPeriod, Subject
+from .models import Lesson, LessonPeriod
 from .util.change_tracker import timetable_data_changed
 from .util.notifications import send_notifications_for_object
 
@@ -134,15 +133,10 @@ def previous_lesson(self, lesson_period: "LessonPeriod", day: date) -> Union["Le
 
 def for_timetables(cls):
     """Return all announcements that should be shown in timetable views."""
-    return cls.objects.filter(show_in_timetables=True)
+    return cls.objects.all()
 
 
 Announcement.class_method(for_timetables)
-Announcement.field(
-    show_in_timetables=BooleanField(verbose_name=_("Show announcement in timetable views?"))
-)
-
-Group.foreign_key("subject", Subject, related_name="groups")
 
 # Dynamically add extra permissions to Group and Person models in core
 # Note: requires migrate afterwards
diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py
index 11ba2702e648d0eff7e6eb5c29a3748a8f8329c5..230520c67dba948326b3bc6a2ee50c6ef0db5276 100644
--- a/aleksis/apps/chronos/models.py
+++ b/aleksis/apps/chronos/models.py
@@ -2,15 +2,13 @@
 from __future__ import annotations
 
 import itertools
-import os
+from collections.abc import Iterable, Iterator
 from datetime import date, datetime, time, timedelta
 from itertools import chain
-from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
+from typing import Any
 
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
-from django.core.files.base import ContentFile, File
-from django.core.files.storage import default_storage
+from django.core.exceptions import PermissionDenied, ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
 from django.db.models import Max, Min, Q
@@ -26,8 +24,6 @@ from django.utils.translation import gettext_lazy as _
 
 from cache_memoize import cache_memoize
 from calendarweek.django import CalendarWeek, i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy
-from celery.result import allow_join_result
-from celery.states import SUCCESS
 from colorfield.fields import ColorField
 from model_utils import FieldTracker
 from reversion.models import Revision, Version
@@ -35,7 +31,6 @@ from reversion.models import Revision, Version
 from aleksis.apps.chronos.managers import (
     AbsenceQuerySet,
     BreakManager,
-    CurrentSiteManager,
     EventManager,
     EventQuerySet,
     ExtraLessonManager,
@@ -64,7 +59,7 @@ from aleksis.apps.chronos.util.format import format_m2m
 from aleksis.apps.cursus import models as cursus_models
 from aleksis.apps.cursus.models import Course
 from aleksis.apps.resint.models import LiveDocument
-from aleksis.core.managers import CurrentSiteManagerWithoutMigrations, PolymorphicCurrentSiteManager
+from aleksis.core.managers import AlekSISBaseManagerWithoutMigrations, PolymorphicBaseManager
 from aleksis.core.mixins import (
     ExtensibleModel,
     GlobalPermissionModel,
@@ -72,7 +67,6 @@ from aleksis.core.mixins import (
 )
 from aleksis.core.models import CalendarEvent, DashboardWidget, Group, Person, Room, SchoolTerm
 from aleksis.core.util.core_helpers import has_person
-from aleksis.core.util.pdf import generate_pdf_from_template
 
 
 class ValidityRange(ExtensibleModel):
@@ -81,7 +75,7 @@ class ValidityRange(ExtensibleModel):
     This is used to link data to a validity range.
     """
 
-    objects = CurrentSiteManagerWithoutMigrations.from_queryset(ValidityRangeQuerySet)()
+    objects = AlekSISBaseManagerWithoutMigrations.from_queryset(ValidityRangeQuerySet)()
 
     school_term = models.ForeignKey(
         SchoolTerm,
@@ -96,7 +90,7 @@ class ValidityRange(ExtensibleModel):
 
     @classmethod
     @cache_memoize(3600)
-    def get_current(cls, day: Optional[date] = None):
+    def get_current(cls, day: date | None = None):
         if not day:
             day = timezone.now().date()
         try:
@@ -113,12 +107,11 @@ class ValidityRange(ExtensibleModel):
         if self.date_end < self.date_start:
             raise ValidationError(_("The start date must be earlier than the end date."))
 
-        if self.school_term:
-            if (
-                self.date_end > self.school_term.date_end
-                or self.date_start < self.school_term.date_start
-            ):
-                raise ValidationError(_("The validity range must be within the school term."))
+        if self.school_term and (
+            self.date_end > self.school_term.date_end
+            or self.date_start < self.school_term.date_start
+        ):
+            raise ValidationError(_("The validity range must be within the school term."))
 
         qs = ValidityRange.objects.within_dates(self.date_start, self.date_end)
         if self.pk:
@@ -135,7 +128,6 @@ class ValidityRange(ExtensibleModel):
         verbose_name = _("Validity range")
         verbose_name_plural = _("Validity ranges")
         constraints = [
-            # Heads up: Uniqueness per term implies uniqueness per site
             models.UniqueConstraint(
                 fields=["school_term", "date_start", "date_end"], name="unique_dates_per_term"
             ),
@@ -161,14 +153,14 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
         return f"{self.get_weekday_display()}, {self.period}."
 
     @classmethod
-    def get_times_dict(cls) -> Dict[int, Tuple[datetime, datetime]]:
+    def get_times_dict(cls) -> dict[int, tuple[datetime, datetime]]:
         periods = {}
         for period in cls.objects.for_current_or_all().all():
             periods[period.period] = (period.time_start, period.time_end)
 
         return periods
 
-    def get_date(self, week: Optional[CalendarWeek] = None) -> date:
+    def get_date(self, week: CalendarWeek | None = None) -> date:
         if isinstance(week, CalendarWeek):
             wanted_week = week
         else:
@@ -179,37 +171,26 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
         return wanted_week[self.weekday]
 
-    def get_datetime_start(
-        self, date_ref: Optional[Union[CalendarWeek, int, date]] = None
-    ) -> datetime:
+    def get_datetime_start(self, date_ref: CalendarWeek | int | date | None = None) -> datetime:
         """Get datetime of lesson start in a specific week."""
-        if isinstance(date_ref, date):
-            day = date_ref
-        else:
-            day = self.get_date(date_ref)
+        day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref)
         return datetime.combine(day, self.time_start)
 
-    def get_datetime_end(
-        self, date_ref: Optional[Union[CalendarWeek, int, date]] = None
-    ) -> datetime:
+    def get_datetime_end(self, date_ref: CalendarWeek | int | date | None = None) -> datetime:
         """Get datetime of lesson end in a specific week."""
-        if isinstance(date_ref, date):
-            day = date_ref
-        else:
-            day = self.get_date(date_ref)
+        day = date_ref if isinstance(date_ref, date) else self.get_date(date_ref)
         return datetime.combine(day, self.time_end)
 
     @classmethod
     def get_next_relevant_day(
-        cls, day: Optional[date] = None, time: Optional[time] = None, prev: bool = False
+        cls, day: date | None = None, time: time | None = None, prev: bool = False
     ) -> date:
         """Return next (previous) day with lessons depending on date and time."""
         if day is None:
             day = timezone.now().date()
 
-        if time is not None and cls.time_max and not prev:
-            if time > cls.time_max:
-                day += timedelta(days=1)
+        if time is not None and cls.time_max and not prev and time > cls.time_max:
+            day += timedelta(days=1)
 
         cw = CalendarWeek.from_date(day)
 
@@ -229,7 +210,7 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
         return day
 
     @classmethod
-    def get_relevant_week_from_datetime(cls, when: Optional[datetime] = None) -> CalendarWeek:
+    def get_relevant_week_from_datetime(cls, when: datetime | None = None) -> CalendarWeek:
         """Return currently relevant week depending on current date and time."""
         if not when:
             when = timezone.now()
@@ -239,15 +220,15 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
         week = CalendarWeek.from_date(day)
 
-        if cls.weekday_max and day.weekday() > cls.weekday_max:
-            week += 1
-        elif cls.time_max and time > cls.time_max and day.weekday() == cls.weekday_max:
+        if (cls.weekday_max and day.weekday() > cls.weekday_max) or (
+            cls.time_max and time > cls.time_max and day.weekday() == cls.weekday_max
+        ):
             week += 1
 
         return week
 
     @classmethod
-    def get_prev_next_by_day(cls, day: date, url: str) -> Tuple[str, str]:
+    def get_prev_next_by_day(cls, day: date, url: str) -> tuple[str, str]:
         """Build URLs for previous/next day."""
         day_prev = cls.get_next_relevant_day(day - timedelta(days=1), prev=True)
         day_next = cls.get_next_relevant_day(day + timedelta(days=1))
@@ -258,7 +239,7 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
         return url_prev, url_next
 
     @classmethod
-    def from_period(cls, period: int, day: date) -> "TimePeriod":
+    def from_period(cls, period: int, day: date) -> TimePeriod:
         """Get `TimePeriod` object for a period on a specific date.
 
         This will respect the relation to validity ranges.
@@ -285,12 +266,12 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
     @classproperty
     @cache_memoize(3600)
-    def time_min(cls) -> Optional[time]:
+    def time_min(cls) -> time | None:
         return cls.objects.for_current_or_all().aggregate(Min("time_start")).get("time_start__min")
 
     @classproperty
     @cache_memoize(3600)
-    def time_max(cls) -> Optional[time]:
+    def time_max(cls) -> time | None:
         return cls.objects.for_current_or_all().aggregate(Max("time_end")).get("time_end__max")
 
     @classproperty
@@ -313,7 +294,7 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
     @classproperty
     @cache_memoize(3600)
-    def period_choices(cls) -> List[Tuple[Union[str, int], str]]:
+    def period_choices(cls) -> list[tuple[str | int, str]]:
         """Build choice list of periods for usage within Django."""
         time_periods = (
             cls.objects.filter(weekday=cls.weekday_min)
@@ -330,7 +311,6 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
     class Meta:
         constraints = [
-            # Heads up: Uniqueness per validity range implies validity per site
             models.UniqueConstraint(
                 fields=["weekday", "period", "validity"], name="unique_period_per_range"
             ),
@@ -342,7 +322,7 @@ class TimePeriod(ValidityRangeRelatedExtensibleModel):
 
 
 class Subject(ExtensibleModel):
-    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
     name = models.CharField(verbose_name=_("Long name"), max_length=255)
 
     colour_fg = ColorField(verbose_name=_("Foreground colour"), blank=True)
@@ -355,11 +335,6 @@ class Subject(ExtensibleModel):
         ordering = ["name", "short_name"]
         verbose_name = _("Subject")
         verbose_name_plural = _("Subjects")
-        constraints = [
-            models.UniqueConstraint(
-                fields=["site_id", "short_name"], name="unique_short_name_per_site_subject"
-            ),
-        ]
 
 
 class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
@@ -411,7 +386,6 @@ class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherP
         return f"{format_m2m(self.groups)}, {self.subject.short_name}, {format_m2m(self.teachers)}"
 
     class Meta:
-        # Heads up: Link to periods implies uniqueness per site
         ordering = ["validity__date_start", "subject"]
         verbose_name = _("Lesson")
         verbose_name_plural = _("Lessons")
@@ -452,7 +426,7 @@ class LessonSubstitution(ExtensibleModel, TeacherPropertiesMixin, WeekRelatedMix
         default=False, verbose_name=_("Cancelled for teachers?")
     )
 
-    comment = models.TextField(verbose_name=_("Comment"), blank=True, null=True)
+    comment = models.TextField(verbose_name=_("Comment"), blank=True)
 
     def clean(self) -> None:
         if self.subject and self.cancelled:
@@ -488,7 +462,6 @@ class LessonSubstitution(ExtensibleModel, TeacherPropertiesMixin, WeekRelatedMix
                 check=~Q(cancelled=True, subject__isnull=False),
                 name="either_substituted_or_cancelled",
             ),
-            # Heads up: Link to period implies uniqueness per site
             models.UniqueConstraint(
                 fields=["lesson_period", "week", "year"], name="unique_period_per_week"
             ),
@@ -527,7 +500,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
         verbose_name=_("Room"),
     )
 
-    def get_substitution(self, week: Optional[CalendarWeek] = None) -> LessonSubstitution:
+    def get_substitution(self, week: CalendarWeek | None = None) -> LessonSubstitution:
         wanted_week = week or self.week or CalendarWeek()
 
         # We iterate over all substitutions because this can make use of
@@ -538,7 +511,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
                 return substitution
         return None
 
-    def get_subject(self) -> Optional[Subject]:
+    def get_subject(self) -> Subject | None:
         sub = self.get_substitution()
         if sub and sub.subject:
             return sub.subject
@@ -552,7 +525,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
         else:
             return self.lesson.teachers
 
-    def get_room(self) -> Optional[Room]:
+    def get_room(self) -> Room | None:
         if self.get_substitution() and self.get_substitution().room:
             return self.get_substitution().room
         else:
@@ -581,7 +554,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
         return LessonPeriod.objects.filter(lesson__in=self.lesson._equal_lessons)
 
     @property
-    def next(self) -> "LessonPeriod":
+    def next(self) -> LessonPeriod:  # noqa
         """Get next lesson period of this lesson.
 
         .. warning::
@@ -590,7 +563,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
         return self._equal_lesson_periods.next_lesson(self)
 
     @property
-    def prev(self) -> "LessonPeriod":
+    def prev(self) -> LessonPeriod:
         """Get previous lesson period of this lesson.
 
         .. warning::
@@ -599,7 +572,7 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
         return self._equal_lesson_periods.next_lesson(self, -1)
 
     def is_replaced_by_event(
-        self, events: Iterable[Event], groups: Optional[Iterable[Group]] = None
+        self, events: Iterable[Event], groups: Iterable[Group] | None = None
     ) -> bool:
         """Check if this lesson period is replaced by an event."""
         groups_of_event = set(chain(*[event.groups.all() for event in events]))
@@ -630,7 +603,6 @@ class LessonPeriod(WeekAnnotationMixin, TeacherPropertiesMixin, ExtensibleModel)
                 return True
 
     class Meta:
-        # Heads up: Link to period implies uniqueness per site
         ordering = [
             "lesson__validity__date_start",
             "period__weekday",
@@ -685,8 +657,8 @@ class TimetableWidget(DashboardWidget):
 
 
 class AbsenceReason(ExtensibleModel):
-    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
-    name = models.CharField(verbose_name=_("Name"), blank=True, null=True, max_length=255)
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
+    name = models.CharField(verbose_name=_("Name"), blank=True, max_length=255)
 
     def __str__(self):
         if self.name:
@@ -697,15 +669,10 @@ class AbsenceReason(ExtensibleModel):
     class Meta:
         verbose_name = _("Absence reason")
         verbose_name_plural = _("Absence reasons")
-        constraints = [
-            models.UniqueConstraint(
-                fields=["site_id", "short_name"], name="unique_short_name_per_site_absence_reason"
-            ),
-        ]
 
 
 class Absence(SchoolTermRelatedExtensibleModel):
-    objects = CurrentSiteManager.from_queryset(AbsenceQuerySet)()
+    objects = AlekSISBaseManagerWithoutMigrations.from_queryset(AbsenceQuerySet)()
 
     reason = models.ForeignKey(
         "AbsenceReason",
@@ -757,7 +724,7 @@ class Absence(SchoolTermRelatedExtensibleModel):
         null=True,
         related_name="+",
     )
-    comment = models.TextField(verbose_name=_("Comment"), blank=True, null=True)
+    comment = models.TextField(verbose_name=_("Comment"), blank=True)
 
     def __str__(self):
         if self.teacher:
@@ -770,7 +737,6 @@ class Absence(SchoolTermRelatedExtensibleModel):
             return _("Unknown absence")
 
     class Meta:
-        # Heads up: Link to period implies uniqueness per site
         ordering = ["date_start"]
         indexes = [models.Index(fields=["date_start", "date_end"])]
         verbose_name = _("Absence")
@@ -803,7 +769,6 @@ class Exam(SchoolTermRelatedExtensibleModel):
     comment = models.TextField(verbose_name=_("Comment"), blank=True)
 
     class Meta:
-        # Heads up: Link to period implies uniqueness per site
         ordering = ["date"]
         indexes = [models.Index(fields=["date"])]
         verbose_name = _("Exam")
@@ -811,12 +776,12 @@ class Exam(SchoolTermRelatedExtensibleModel):
 
 
 class Holiday(ExtensibleModel):
-    objects = CurrentSiteManager.from_queryset(HolidayQuerySet)()
+    objects = AlekSISBaseManagerWithoutMigrations.from_queryset(HolidayQuerySet)()
 
     title = models.CharField(verbose_name=_("Title"), max_length=255)
     date_start = models.DateField(verbose_name=_("Start date"), null=True)
     date_end = models.DateField(verbose_name=_("End date"), null=True)
-    comments = models.TextField(verbose_name=_("Comments"), blank=True, null=True)
+    comments = models.TextField(verbose_name=_("Comments"), blank=True)
 
     def get_days(self) -> Iterator[date]:
         delta = self.date_end - self.date_start
@@ -824,7 +789,7 @@ class Holiday(ExtensibleModel):
             yield self.date_start + timedelta(days=i)
 
     @classmethod
-    def on_day(cls, day: date) -> Optional["Holiday"]:
+    def on_day(cls, day: date) -> Holiday | None:
         holidays = cls.objects.on_day(day)
         if holidays.exists():
             return holidays[0]
@@ -832,7 +797,7 @@ class Holiday(ExtensibleModel):
             return None
 
     @classmethod
-    def in_week(cls, week: CalendarWeek) -> Dict[int, Optional["Holiday"]]:
+    def in_week(cls, week: CalendarWeek) -> dict[int, Holiday | None]:
         per_weekday = {}
         holidays = Holiday.objects.in_week(week)
 
@@ -860,7 +825,7 @@ class Holiday(ExtensibleModel):
 
 
 class SupervisionArea(ExtensibleModel):
-    short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
+    short_name = models.CharField(verbose_name=_("Short name"), max_length=255, unique=True)
     name = models.CharField(verbose_name=_("Long name"), max_length=255)
     colour_fg = ColorField(default="#000000")
     colour_bg = ColorField()
@@ -872,11 +837,6 @@ class SupervisionArea(ExtensibleModel):
         ordering = ["name"]
         verbose_name = _("Supervision area")
         verbose_name_plural = _("Supervision areas")
-        constraints = [
-            models.UniqueConstraint(
-                fields=["site_id", "short_name"], name="unique_short_name_per_site_supervision_area"
-            ),
-        ]
 
 
 class Break(ValidityRangeRelatedExtensibleModel):
@@ -923,7 +883,7 @@ class Break(ValidityRangeRelatedExtensibleModel):
         return self.before_period.time_start if self.before_period else None
 
     @classmethod
-    def get_breaks_dict(cls) -> Dict[int, Tuple[datetime, datetime]]:
+    def get_breaks_dict(cls) -> dict[int, tuple[datetime, datetime]]:
         breaks = {}
         for break_ in cls.objects.all():
             breaks[break_.before_period_number] = break_
@@ -940,7 +900,7 @@ class Break(ValidityRangeRelatedExtensibleModel):
         verbose_name_plural = _("Breaks")
         constraints = [
             models.UniqueConstraint(
-                fields=["validity", "short_name"], name="unique_short_name_per_site_break"
+                fields=["validity", "short_name"], name="unique_short_name_per_validity_break"
             ),
         ]
 
@@ -975,9 +935,7 @@ class Supervision(ValidityRangeRelatedExtensibleModel, WeekAnnotationMixin):
 
         return CalendarWeek(year=year, week=week)
 
-    def get_substitution(
-        self, week: Optional[CalendarWeek] = None
-    ) -> Optional[SupervisionSubstitution]:
+    def get_substitution(self, week: CalendarWeek | None = None) -> SupervisionSubstitution | None:
         wanted_week = week or self.week or CalendarWeek()
         # We iterate over all substitutions because this can make use of
         # prefetching when this model is loaded from outside, in contrast
@@ -1051,7 +1009,7 @@ class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPrope
 
     objects = EventManager.from_queryset(EventQuerySet)()
 
-    title = models.CharField(verbose_name=_("Title"), max_length=255, blank=True, null=True)
+    title = models.CharField(verbose_name=_("Title"), max_length=255, blank=True)
 
     date_start = models.DateField(verbose_name=_("Start date"), null=True)
     date_end = models.DateField(verbose_name=_("End date"), null=True)
@@ -1169,7 +1127,6 @@ class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPrope
         ), timezone.datetime.combine(self.date_end, self.period_to.time_end)
 
     class Meta:
-        # Heads up: Link to period implies uniqueness per site
         ordering = ["date_start"]
         indexes = [
             models.Index(
@@ -1222,7 +1179,7 @@ class ExtraLesson(
         verbose_name=_("Room"),
     )
 
-    comment = models.CharField(verbose_name=_("Comment"), blank=True, null=True, max_length=255)
+    comment = models.CharField(verbose_name=_("Comment"), blank=True, max_length=255)
 
     exam = models.ForeignKey(
         "Exam",
@@ -1256,7 +1213,6 @@ class ExtraLesson(
         ), timezone.datetime.combine(self.date, self.period.time_end)
 
     class Meta:
-        # Heads up: Link to period implies uniqueness per site
         verbose_name = _("Extra lesson")
         verbose_name_plural = _("Extra lessons")
         indexes = [models.Index(fields=["week", "year"], name="extra_lesson_week_year")]
@@ -1296,7 +1252,7 @@ class AutomaticPlan(LiveDocument):
         """Get last day which should be shown in the PDF."""
         return self.current_start_day + timedelta(days=self.number_of_days - 1)
 
-    def get_context_data(self) -> Dict[str, Any]:
+    def get_context_data(self) -> dict[str, Any]:
         """Get context data for generating the substitutions PDF."""
         from aleksis.apps.chronos.util.chronos_helpers import get_substitutions_context_data  # noqa
 
@@ -1371,7 +1327,7 @@ class LessonEvent(CalendarEvent):
     name = "lesson"
     verbose_name = _("Lessons")
 
-    objects = PolymorphicCurrentSiteManager.from_queryset(LessonEventQuerySet)()
+    objects = PolymorphicBaseManager.from_queryset(LessonEventQuerySet)()
 
     title = models.CharField(verbose_name=_("Name"), max_length=255, blank=True)
 
@@ -1418,34 +1374,34 @@ class LessonEvent(CalendarEvent):
     )
 
     @property
-    def actual_groups(self: "LessonEvent"):
+    def actual_groups(self: LessonEvent):
         return self.groups.all() if self.amends else self.real_amends.groups.all()
 
     @property
-    def all_members(self: "LessonEvent") -> list[Person]:
+    def all_members(self: LessonEvent) -> list[Person]:
         return list(itertools.chain(*[list(g.members.all()) for g in self.actual_groups]))
 
     @property
-    def all_teachers(self: "LessonEvent") -> list[Person]:
+    def all_teachers(self: LessonEvent) -> list[Person]:
         all_teachers = list(self.teachers.all())
         if self.amends:
             all_teachers += list(self.real_amends.teachers.all())
         return all_teachers
 
     @property
-    def group_names(self: "LessonEvent") -> str:
+    def group_names(self: LessonEvent) -> str:
         return ", ".join([g.name for g in self.actual_groups])
 
     @property
-    def teacher_names(self: "LessonEvent") -> str:
+    def teacher_names(self: LessonEvent) -> str:
         return ", ".join([t.full_name for t in self.teachers.all()])
 
     @property
-    def room_names(self: "LessonEvent") -> str:
+    def room_names(self: LessonEvent) -> str:
         return ", ".join([r.name for r in self.rooms.all()])
 
     @property
-    def room_names_with_amends(self: "LessonEvent") -> str:
+    def room_names_with_amends(self: LessonEvent) -> str:
         my_room_names = self.room_names
         amended_room_names = self.real_amends.room_names if self.amends else ""
 
@@ -1456,7 +1412,7 @@ class LessonEvent(CalendarEvent):
         return my_room_names
 
     @property
-    def teacher_names_with_amends(self: "LessonEvent") -> str:
+    def teacher_names_with_amends(self: LessonEvent) -> str:
         my_teacher_names = self.teacher_names
         amended_teacher_names = self.real_amends.teacher_names if self.amends else ""
 
@@ -1467,8 +1423,8 @@ class LessonEvent(CalendarEvent):
         return my_teacher_names
 
     @property
-    def subject_name_with_amends(self: "LessonEvent") -> str:
-        my_subject = self.subject.name if self.subject else ""
+    def subject_name_with_amends(self: LessonEvent) -> str:
+        my_subject = self.subject.name
         amended_subject = self.real_amends.subject.name if self.amends else ""
 
         if my_subject and amended_subject:
@@ -1480,14 +1436,14 @@ class LessonEvent(CalendarEvent):
         return _("Lesson")
 
     @property
-    def real_amends(self: "LessonEvent") -> "LessonEvent":
+    def real_amends(self: LessonEvent) -> LessonEvent:
         # FIXME THIS IS AWFUL SLOW
         if self.amends:
             return LessonEvent.objects.get(pk=self.amends.pk)
         return self
 
     @classmethod
-    def value_title(cls, reference_object: "LessonEvent", request) -> str:
+    def value_title(cls, reference_object: LessonEvent, request) -> str:
         """Get the title of the event."""
         if reference_object.title:
             return reference_object.title
@@ -1506,11 +1462,11 @@ class LessonEvent(CalendarEvent):
         return _("Lesson")
 
     @classmethod
-    def value_description(cls, reference_object: "LessonEvent", request) -> str:
+    def value_description(cls, reference_object: LessonEvent, request) -> str:
         return render_to_string("chronos/lesson_event_description.txt", {"event": reference_object})
 
     @classmethod
-    def value_color(cls, reference_object: "LessonEvent", request) -> str:
+    def value_color(cls, reference_object: LessonEvent, request) -> str:
         """Get the color of the event."""
         if reference_object.cancelled:
             return "#eeeeee"
@@ -1521,25 +1477,25 @@ class LessonEvent(CalendarEvent):
         return super().value_color(reference_object, request)
 
     @classmethod
-    def value_attendee(cls, reference_object: "LessonEvent", request) -> str:
+    def value_attendee(cls, reference_object: LessonEvent, request) -> str:
         """Get the attendees of the event."""
         attendees = [t.get_vcal_address(role="CHAIR") for t in reference_object.teachers.all()]
         return [a for a in attendees if a]
 
     @classmethod
-    def value_location(cls, reference_object: "LessonEvent", request) -> str:
+    def value_location(cls, reference_object: LessonEvent, request) -> str:
         """Get the location of the event."""
         return ", ".join([r.name for r in reference_object.rooms.all()])
 
     @classmethod
-    def value_status(cls, reference_object: "LessonEvent", request) -> str:
+    def value_status(cls, reference_object: LessonEvent, request) -> str:
         """Get the status of the event."""
         if reference_object.cancelled:
             return "CANCELLED"
         return "CONFIRMED"
 
     @classmethod
-    def value_meta(cls, reference_object: "LessonEvent", request) -> str:
+    def value_meta(cls, reference_object: LessonEvent, request) -> str:
         """Get the meta of the event."""
         real_amends = reference_object.real_amends
 
@@ -1584,17 +1540,40 @@ class LessonEvent(CalendarEvent):
     def get_objects(cls, request, params=None) -> Iterable:
         """Return all objects that should be included in the calendar."""
         objs = super().get_objects(request, params).not_instance_of(SupervisionEvent)
+
+        if not has_person(request.user):
+            raise PermissionDenied()
+
         if params:
             obj_id = int(params.get("id", 0))
-            type = params.get("type", None)
+            type_ = params.get("type", None)
+            not_amended = params.get("not_amended", False)
+            not_amending = params.get("not_amending", False)
+            own = params.get("own", False)
+
+            if not_amended:
+                objs = objs.not_amended()
+
+            if not_amending:
+                objs = objs.not_amending()
+
+            if "own" in params:
+                if own:
+                    objs = objs.for_person(request.user.person)
+                else:
+                    objs = objs.related_to_person(request.user.person)
 
-            if type and obj_id:
-                if type == "TEACHER":
+            if type_ and obj_id:
+                if type_ == "TEACHER":
                     return objs.for_teacher(obj_id)
-                elif type == "GROUP":
+                elif type_ == "GROUP":
                     return objs.for_group(obj_id)
-                elif type == "ROOM":
+                elif type_ == "ROOM":
                     return objs.for_room(obj_id)
+                elif type_ == "COURSE":
+                    return objs.for_course(obj_id)
+            elif "own" in params:
+                return objs
         return objs.for_person(request.user.person)
 
     class Meta:
@@ -1606,16 +1585,16 @@ class SupervisionEvent(LessonEvent):
     name = "supervision"
     verbose_name = _("Supervisions")
 
-    objects = PolymorphicCurrentSiteManager.from_queryset(LessonEventQuerySet)()
+    objects = PolymorphicBaseManager.from_queryset(LessonEventQuerySet)()
 
     @classmethod
-    def value_title(cls, reference_object: "LessonEvent", request) -> str:
+    def value_title(cls, reference_object: LessonEvent, request) -> str:
         """Get the title of the event."""
 
         return _("Supervision: {}").format(reference_object.room_names)
 
     @classmethod
-    def value_description(cls, reference_object: "LessonEvent", request) -> str:
+    def value_description(cls, reference_object: LessonEvent, request) -> str:
         return render_to_string(
             "chronos/supervision_event_description.txt", {"event": reference_object}
         )
@@ -1626,13 +1605,13 @@ class SupervisionEvent(LessonEvent):
         objs = cls.objects.instance_of(cls)
         if params:
             obj_id = int(params.get("id", 0))
-            type = params.get("type", None)
+            type_ = params.get("type", None)
 
-            if type and obj_id:
-                if type == "TEACHER":
+            if type_ and obj_id:
+                if type_ == "TEACHER":
                     return objs.for_teacher(obj_id)
-                elif type == "GROUP":
+                elif type_ == "GROUP":
                     return objs.for_group(obj_id)
-                elif type == "ROOM":
+                elif type_ == "ROOM":
                     return objs.for_room(obj_id)
         return objs.for_person(request.user.person)
diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py
index f7cc25ff46c7fe7018876cc782fb6ad4e92e4eca..0bdbe2dbb8988d04fb351fb8758e709cbdea1aeb 100644
--- a/aleksis/apps/chronos/schema/__init__.py
+++ b/aleksis/apps/chronos/schema/__init__.py
@@ -139,6 +139,15 @@ class TimetableObjectType(graphene.ObjectType):
         return f"{root.type.value}-{root.id}"
 
 
+class LessonEventType(DjangoObjectType):
+    class Meta:
+        model = LessonEvent
+        fields = ("id",)
+        filter_fields = {
+            "id": ["exact", "lte", "gte"],
+        }
+
+
 class Query(graphene.ObjectType):
     timetable_teachers = graphene.List(TimetablePersonType)
     timetable_groups = graphene.List(TimetableGroupType)
diff --git a/aleksis/apps/chronos/tables.py b/aleksis/apps/chronos/tables.py
index 07c72aee28de6738206f5506f8cef629cace56ec..7a60b1f6254cfef5bfaf2a415c5bfcff58f6156c 100644
--- a/aleksis/apps/chronos/tables.py
+++ b/aleksis/apps/chronos/tables.py
@@ -1,7 +1,5 @@
 from __future__ import annotations
 
-from typing import Optional, Union
-
 from django.utils.html import format_html
 from django.utils.translation import gettext_lazy as _
 
@@ -12,8 +10,8 @@ from .models import LessonPeriod, Supervision
 
 
 def _title_attr_from_lesson_or_supervision_state(
-    record: Optional[Union[LessonPeriod, Supervision]] = None,
-    table: Optional[Union[LessonsTable, SupervisionsTable]] = None,
+    record: LessonPeriod | Supervision | None = None,
+    table: LessonsTable | SupervisionsTable | None = None,
 ) -> str:
     """Return HTML title depending on lesson or supervision state."""
     if record.get_substitution():
@@ -26,7 +24,7 @@ def _title_attr_from_lesson_or_supervision_state(
 
 
 class SubstitutionColumn(tables.Column):
-    def render(self, value, record: Optional[Union[LessonPeriod, Supervision]] = None):
+    def render(self, value, record: LessonPeriod | Supervision | None = None):
         if record.get_substitution():
             return (
                 format_html(
@@ -48,7 +46,7 @@ class SubstitutionColumn(tables.Column):
 
 
 class LessonStatusColumn(tables.Column):
-    def render(self, record: Optional[Union[LessonPeriod, Supervision]] = None):
+    def render(self, record: LessonPeriod | Supervision | None = None):
         if record.get_substitution():
             return (
                 format_html(
diff --git a/aleksis/apps/chronos/templates/chronos/lesson_event_description.txt b/aleksis/apps/chronos/templates/chronos/lesson_event_description.txt
index 573c92354535f0f4a5ed029e9efab63bc71c3639..0b9a2edc8f0e32ccdb6bc9c4181bfbbff35a26eb 100644
--- a/aleksis/apps/chronos/templates/chronos/lesson_event_description.txt
+++ b/aleksis/apps/chronos/templates/chronos/lesson_event_description.txt
@@ -3,4 +3,4 @@
 {% trans "Teachers" %}: {{ event.teacher_names_with_amends|default:"–" }}
 {% trans "Rooms" %}: {{ event.room_names_with_amends|default:"–" }}{% if event.comment %}
 
-{{ event.comment }}{% endif %}
\ No newline at end of file
+{{ event.comment }}{% endif %}
diff --git a/aleksis/apps/chronos/templates/chronos/supervision_event_description.txt b/aleksis/apps/chronos/templates/chronos/supervision_event_description.txt
index d2f311f240e5cf527033bc08de15869085dc0ee5..dee2e549c16c765884c47a28d3aa5db311be6dee 100644
--- a/aleksis/apps/chronos/templates/chronos/supervision_event_description.txt
+++ b/aleksis/apps/chronos/templates/chronos/supervision_event_description.txt
@@ -1,2 +1,2 @@
 {% load i18n %}{% trans "Teachers" %}: {{ event.teacher_names }}
-{% trans "Areas" %}: {{ event.room_names }}
\ No newline at end of file
+{% trans "Areas" %}: {{ event.room_names }}
diff --git a/aleksis/apps/chronos/tests/test_notifications.py b/aleksis/apps/chronos/tests/test_notifications.py
index 9180bc22f997093bd24d594083efc2b0c10969af..2e2393cb2b3fa962613615115ce57966b538af7a 100644
--- a/aleksis/apps/chronos/tests/test_notifications.py
+++ b/aleksis/apps/chronos/tests/test_notifications.py
@@ -32,26 +32,26 @@ class NotificationTests(TransactionTestCase):
         )
 
         self.teacher_a = Person.objects.create(
-            first_name="Teacher", last_name="A", short_name="A", email="test@example.org"
+            first_name="Teacher", last_name="A", short_name="A", email="test1@example.org"
         )
         self.teacher_b = Person.objects.create(
-            first_name="Teacher", last_name="B", short_name="B", email="test@example.org"
+            first_name="Teacher", last_name="B", short_name="B", email="test2@example.org"
         )
 
         self.student_a = Person.objects.create(
-            first_name="Student", last_name="A", email="test@example.org"
+            first_name="Student", last_name="A", email="test3@example.org"
         )
         self.student_b = Person.objects.create(
-            first_name="Student", last_name="B", email="test@example.org"
+            first_name="Student", last_name="B", email="test4@example.org"
         )
         self.student_c = Person.objects.create(
-            first_name="Student", last_name="C", email="test@example.org"
+            first_name="Student", last_name="C", email="test5@example.org"
         )
         self.student_d = Person.objects.create(
-            first_name="Student", last_name="D", email="test@example.org"
+            first_name="Student", last_name="D", email="test6@example.org"
         )
         self.student_e = Person.objects.create(
-            first_name="Student", last_name="E", email="test@example.org"
+            first_name="Student", last_name="E", email="test7@example.org"
         )
 
         self.group_a = Group.objects.create(
diff --git a/aleksis/apps/chronos/util/build.py b/aleksis/apps/chronos/util/build.py
index f79ecf6484dbaa18f22447ad9a0a0c57160add96..625998db7a20e293f0f8edc3e4f04f4109c3c9db 100644
--- a/aleksis/apps/chronos/util/build.py
+++ b/aleksis/apps/chronos/util/build.py
@@ -1,6 +1,6 @@
 from collections import OrderedDict
 from datetime import date
-from typing import List, Tuple, Union
+from typing import Union
 
 from django.apps import apps
 
@@ -113,10 +113,7 @@ def build_timetable(
 
     # Get events
     events = Event.objects
-    if is_week:
-        events = events.in_week(date_ref)
-    else:
-        events = events.on_day(date_ref)
+    events = events.in_week(date_ref) if is_week else events.on_day(date_ref)
 
     events = events.only(
         "id",
@@ -161,19 +158,13 @@ def build_timetable(
                 # If daily timetable for person, skip other weekdays
                 continue
 
-            if weekday == weekday_from:
-                # If start day, use start period
-                period_from = period_from_first_weekday
-            else:
-                # If not start day, use min period
-                period_from = TimePeriod.period_min
+            # If start day, use start period else use min period
+            period_from = (
+                period_from_first_weekday if weekday == weekday_from else TimePeriod.period_min
+            )
 
-            if weekday == weekday_to:
-                # If end day, use end period
-                period_to = period_to_last_weekday
-            else:
-                # If not end day, use max period
-                period_to = TimePeriod.period_max
+            # If end day, use end period else use max period
+            period_to = period_to_last_weekday if weekday == weekday_to else TimePeriod.periox_max
 
             for period in range(period_from, period_to + 1):
                 # The following events are possibly replacing some lesson periods
@@ -203,10 +194,7 @@ def build_timetable(
 
     if type_ == TimetableType.TEACHER:
         # Get matching supervisions
-        if not is_week:
-            week = CalendarWeek.from_date(date_ref)
-        else:
-            week = date_ref
+        week = CalendarWeek.from_date(date_ref) if not is_week else date_ref
         supervisions = (
             Supervision.objects.in_week(week)
             .all()
@@ -275,9 +263,8 @@ def build_timetable(
                     if (
                         period in supervisions_per_period_after
                         and weekday not in holidays_per_weekday
-                    ):
-                        if weekday in supervisions_per_period_after[period]:
-                            col = supervisions_per_period_after[period][weekday]
+                    ) and weekday in supervisions_per_period_after[period]:
+                        col = supervisions_per_period_after[period][weekday]
                     cols.append(col)
 
                 row["cols"] = cols
@@ -336,7 +323,7 @@ def build_timetable(
                                 )
                                 lesson_period.replaced_by_event = replaced_by_event
                                 if not replaced_by_event or (
-                                    replaced_by_event and not type_ == TimetableType.GROUP
+                                    replaced_by_event and type_ != TimetableType.GROUP
                                 ):
                                     col.append(lesson_period)
 
@@ -393,7 +380,7 @@ def build_timetable(
     return rows
 
 
-def build_substitutions_list(wanted_day: date) -> List[dict]:
+def build_substitutions_list(wanted_day: date) -> list[dict]:
     rows = []
 
     subs = LessonSubstitution.objects.on_day(wanted_day).order_by(
@@ -466,10 +453,7 @@ def build_substitutions_list(wanted_day: date) -> List[dict]:
     events = Event.objects.on_day(wanted_day).annotate_day(wanted_day)
 
     for event in events:
-        if event.groups.all():
-            sort_a = event.group_names
-        else:
-            sort_a = f"Z.{event.teacher_names}"
+        sort_a = event.group_names if event.groups.all() else f"Z.{event.teacher_names}"
 
         row = {
             "type": "event",
@@ -489,8 +473,8 @@ def build_substitutions_list(wanted_day: date) -> List[dict]:
 
 
 def build_weekdays(
-    base: List[Tuple[int, str]], wanted_week: CalendarWeek, with_holidays: bool = True
-) -> List[dict]:
+    base: list[tuple[int, str]], wanted_week: CalendarWeek, with_holidays: bool = True
+) -> list[dict]:
     if with_holidays:
         holidays_per_weekday = Holiday.in_week(wanted_week)
 
diff --git a/aleksis/apps/chronos/util/change_tracker.py b/aleksis/apps/chronos/util/change_tracker.py
index 348899dd30cb3328831b6cd859a1aaf68bebcfed..c9b8afb1b94c9bd5630a50b36d36e08ba58d31fd 100644
--- a/aleksis/apps/chronos/util/change_tracker.py
+++ b/aleksis/apps/chronos/util/change_tracker.py
@@ -1,4 +1,4 @@
-from typing import Any, Optional, Type
+from typing import Any, Optional
 
 from django.contrib.contenttypes.models import ContentType
 from django.db import transaction
@@ -41,7 +41,7 @@ class TimetableDataChangeTracker:
     """Helper class for tracking changes in timetable models by using signals."""
 
     @classmethod
-    def get_models(cls) -> list[Type[Model]]:
+    def get_models(cls) -> list[type[Model]]:
         """Return all models that should be tracked."""
         from aleksis.apps.chronos.models import (
             Event,
@@ -70,7 +70,7 @@ class TimetableDataChangeTracker:
                 if f.many_to_many
             }
             self.m2m_fields.update(m2m_fields)
-            for through_model, field in m2m_fields.items():
+            for through_model, _field in m2m_fields.items():
                 m2m_changed.connect(self._handle_m2m_changed, sender=through_model, weak=False)
 
         transaction.on_commit(self.close)
@@ -87,24 +87,24 @@ class TimetableDataChangeTracker:
         else:
             self.changes[key].changed_fields.update(change.changed_fields)
 
-    def _handle_save(self, sender: Type[Model], instance: Model, created: bool, **kwargs):
+    def _handle_save(self, sender: type[Model], instance: Model, created: bool, **kwargs):
         """Handle the save signal."""
         change = TimetableChange(instance, created=created)
         if not created:
             change.changed_fields = instance.tracker.changed()
         self._add_change(change)
 
-    def _handle_delete(self, sender: Type[Model], instance: Model, **kwargs):
+    def _handle_delete(self, sender: type[Model], instance: Model, **kwargs):
         """Handle the delete signal."""
         change = TimetableChange(instance, deleted=True)
         self._add_change(change)
 
     def _handle_m2m_changed(
         self,
-        sender: Type[Model],
+        sender: type[Model],
         instance: Model,
         action: str,
-        model: Type[Model],
+        model: type[Model],
         pk_set: set,
         **kwargs,
     ):
@@ -127,7 +127,7 @@ class TimetableDataChangeTracker:
             post_save.disconnect(self._handle_save, sender=model)
             pre_delete.disconnect(self._handle_delete, sender=model)
 
-        for through_model, field in self.m2m_fields.items():
+        for through_model, _field in self.m2m_fields.items():
             m2m_changed.disconnect(self._handle_m2m_changed, sender=through_model)
 
         timetable_data_changed.send(sender=self, changes=self.changes)
diff --git a/aleksis/apps/chronos/util/chronos_helpers.py b/aleksis/apps/chronos/util/chronos_helpers.py
index 5664c0b97e13e68ab18120a2cadfd39396eaa8a4..1f299424047084ceeec346a22b9e9c1aa00127ed 100644
--- a/aleksis/apps/chronos/util/chronos_helpers.py
+++ b/aleksis/apps/chronos/util/chronos_helpers.py
@@ -106,10 +106,7 @@ def get_classes(user: "User"):
             lessons_count=Count("lesson_events"),
             child_lessons_count=Count("child_groups__lesson_events"),
         )
-        .filter(
-            Q(lessons_count__gt=0, parent_groups=None)
-            | Q(child_lessons_count__gt=0, parent_groups=None)
-        )
+        .filter(Q(lessons_count__gt=0) | Q(child_lessons_count__gt=0))
         .order_by("short_name", "name")
     )
 
@@ -189,7 +186,7 @@ def get_substitutions_context_data(
 
     if is_print:
         next_day = wanted_day
-        for i in range(day_number):
+        for _i in range(day_number):
             day_contexts[next_day] = {"day": next_day}
             next_day = TimePeriod.get_next_relevant_day(next_day + timedelta(days=1))
     else:
@@ -199,9 +196,7 @@ def get_substitutions_context_data(
         subs = build_substitutions_list(day)
         day_contexts[day]["substitutions"] = subs
 
-        day_contexts[day]["announcements"] = (
-            Announcement.for_timetables().on_date(day).filter(show_in_timetables=True)
-        )
+        day_contexts[day]["announcements"] = Announcement.for_timetables().on_date(day)
 
         if show_header_box:
             subs = LessonSubstitution.objects.on_day(day).order_by(
diff --git a/aleksis/apps/chronos/util/date.py b/aleksis/apps/chronos/util/date.py
index d25375c7916abdc7664dad085704076eab5d1d5b..9a2eb10592509b12f4e915a5e115b96d95ce939a 100644
--- a/aleksis/apps/chronos/util/date.py
+++ b/aleksis/apps/chronos/util/date.py
@@ -1,12 +1,11 @@
 from datetime import date
-from typing import List, Tuple
 
 from django.utils import timezone
 
 from calendarweek import CalendarWeek
 
 
-def week_weekday_from_date(when: date) -> Tuple[CalendarWeek, int]:
+def week_weekday_from_date(when: date) -> tuple[CalendarWeek, int]:
     """Return a tuple of week and weekday from a given date."""
     return (CalendarWeek.from_date(when), when.weekday())
 
@@ -21,7 +20,7 @@ def week_period_to_date(week: CalendarWeek, period) -> date:
     return period.get_date(week)
 
 
-def get_weeks_for_year(year: int) -> List[CalendarWeek]:
+def get_weeks_for_year(year: int) -> list[CalendarWeek]:
     """Generate all weeks for one year."""
     weeks = []
 
diff --git a/aleksis/apps/chronos/util/format.py b/aleksis/apps/chronos/util/format.py
index 6818947d0d5350754e5bc9a7804b46b4cf56fb60..0dd640f3c6e892741eb81d8de61421681160c9c9 100644
--- a/aleksis/apps/chronos/util/format.py
+++ b/aleksis/apps/chronos/util/format.py
@@ -1,7 +1,11 @@
 from datetime import date
+from typing import TYPE_CHECKING
 
 from django.utils.formats import date_format
 
+if TYPE_CHECKING:
+    from ..models import TimePeriod
+
 
 def format_m2m(f, attr: str = "short_name") -> str:
     """Join a attribute of all elements of a ManyToManyField."""
diff --git a/aleksis/apps/chronos/util/notifications.py b/aleksis/apps/chronos/util/notifications.py
index 2c7d7dbce281c41d03d124ecbde96bf6c63eeb01..847dc599ab47699ec4867d22822f81c8f1ec00ff 100644
--- a/aleksis/apps/chronos/util/notifications.py
+++ b/aleksis/apps/chronos/util/notifications.py
@@ -1,4 +1,3 @@
-import zoneinfo
 from datetime import datetime, timedelta
 from typing import Union
 from urllib.parse import urljoin
@@ -10,6 +9,8 @@ from django.utils.formats import date_format
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import ngettext
 
+import zoneinfo
+
 from aleksis.core.models import Notification, Person
 from aleksis.core.util.core_helpers import get_site_preferences
 
@@ -17,7 +18,7 @@ from ..models import Event, ExtraLesson, LessonSubstitution, SupervisionSubstitu
 
 
 def send_notifications_for_object(
-    instance: Union[ExtraLesson, LessonSubstitution, Event, SupervisionSubstitution]
+    instance: Union[ExtraLesson, LessonSubstitution, Event, SupervisionSubstitution],
 ):
     """Send notifications for a change object."""
     recipients = []
@@ -27,10 +28,7 @@ def send_notifications_for_object(
         recipients += Person.objects.filter(
             member_of__in=instance.lesson_period.lesson.groups.all()
         )
-    elif isinstance(instance, Event):
-        recipients += instance.teachers.all()
-        recipients += Person.objects.filter(member_of__in=instance.groups.all())
-    elif isinstance(instance, ExtraLesson):
+    elif isinstance(instance, (Event, ExtraLesson)):
         recipients += instance.teachers.all()
         recipients += Person.objects.filter(member_of__in=instance.groups.all())
     elif isinstance(instance, SupervisionSubstitution):
diff --git a/aleksis/apps/chronos/views.py b/aleksis/apps/chronos/views.py
index a7ae9b77b322c00f41860c39a3f6cc7ae6376687..8bd3307977dbe5a625df0223edf7a85044ca5f78 100644
--- a/aleksis/apps/chronos/views.py
+++ b/aleksis/apps/chronos/views.py
@@ -291,22 +291,21 @@ def edit_substitution(request: HttpRequest, id_: int, week: int) -> HttpResponse
 
     context["substitution"] = lesson_substitution
 
-    if request.method == "POST":
-        if edit_substitution_form.is_valid():
-            with reversion.create_revision(atomic=True):
-                tracker = TimetableDataChangeTracker()
+    if request.method == "POST" and edit_substitution_form.is_valid():
+        with reversion.create_revision(atomic=True):
+            TimetableDataChangeTracker()
 
-                lesson_substitution = edit_substitution_form.save(commit=False)
-                if not lesson_substitution.pk:
-                    lesson_substitution.lesson_period = lesson_period
-                    lesson_substitution.week = wanted_week.week
-                    lesson_substitution.year = wanted_week.year
-                lesson_substitution.save()
-                edit_substitution_form.save_m2m()
+            lesson_substitution = edit_substitution_form.save(commit=False)
+            if not lesson_substitution.pk:
+                lesson_substitution.lesson_period = lesson_period
+                lesson_substitution.week = wanted_week.week
+                lesson_substitution.year = wanted_week.year
+            lesson_substitution.save()
+            edit_substitution_form.save_m2m()
 
-                messages.success(request, _("The substitution has been saved."))
+            messages.success(request, _("The substitution has been saved."))
 
-            return redirect("lessons_day_by_date", year=day.year, month=day.month, day=day.day)
+        return redirect("lessons_day_by_date", year=day.year, month=day.month, day=day.day)
 
     context["edit_substitution_form"] = edit_substitution_form
 
@@ -432,23 +431,20 @@ def edit_supervision_substitution(request: HttpRequest, id_: int, week: int) ->
 
     context["substitution"] = supervision_substitution
 
-    if request.method == "POST":
-        if edit_supervision_substitution_form.is_valid():
-            with reversion.create_revision(atomic=True):
-                tracker = TimetableDataChangeTracker()
+    if request.method == "POST" and edit_supervision_substitution_form.is_valid():
+        with reversion.create_revision(atomic=True):
+            TimetableDataChangeTracker()
 
-                supervision_substitution = edit_supervision_substitution_form.save(commit=False)
-                if not supervision_substitution.pk:
-                    supervision_substitution.supervision = supervision
-                    supervision_substitution.date = date
-                supervision_substitution.save()
-                edit_supervision_substitution_form.save_m2m()
+            supervision_substitution = edit_supervision_substitution_form.save(commit=False)
+            if not supervision_substitution.pk:
+                supervision_substitution.supervision = supervision
+                supervision_substitution.date = date
+            supervision_substitution.save()
+            edit_supervision_substitution_form.save_m2m()
 
-                messages.success(request, _("The substitution has been saved."))
+            messages.success(request, _("The substitution has been saved."))
 
-            return redirect(
-                "supervisions_day_by_date", year=date.year, month=date.month, day=date.day
-            )
+        return redirect("supervisions_day_by_date", year=date.year, month=date.month, day=date.day)
 
     context["edit_supervision_substitution_form"] = edit_supervision_substitution_form
 
diff --git a/docs/conf.py b/docs/conf.py
index 743bdf5de706d498d24775f49dd925ab819d70dd..019586d7d6674aac00e67f8fd079925ec2e29aa1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -31,7 +31,7 @@ author = "The AlekSIS Team"
 # The short X.Y version
 version = "3.0"
 # The full version, including alpha/beta/rc tags
-release = "3.0.2.dev0"
+release = "3.0.3.dev0"
 
 
 # -- General configuration ---------------------------------------------------
diff --git a/pyproject.toml b/pyproject.toml
index 32832395ff78f72b83016625a3bfd35612da15bf..b399b79ca2354aa14bbdbd02b25003d5f05852b4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "AlekSIS-App-Chronos"
-version = "4.0.dev1"
+version = "4.0.0.dev1"
 packages = [
     { include = "aleksis" }
 ]
@@ -47,35 +47,24 @@ priority = "primary"
 name = "gitlab"
 url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple"
 priority = "supplemental"
+
 [tool.poetry.dependencies]
-python = "^3.9"
+python = "^3.10"
 calendarweek = "^0.5.0"
-aleksis-core = "^4.0.0.dev0"
-aleksis-app-resint = "^4.0.dev0"
+aleksis-core = "^4.0.0.dev2"
+aleksis-app-resint = "^4.0.0.dev1"
 aleksis-app-cursus = "^0.1.dev0"
 
+
 [tool.poetry.plugins."aleksis.app"]
 chronos = "aleksis.apps.chronos.apps:ChronosConfig"
 
 
 [tool.poetry.group.dev.dependencies]
 django-stubs = "^4.2"
-
 safety = "^2.3.5"
 
-flake8 = "^6.0.0"
-flake8-django = "~1.2"
-flake8-fixme = "^1.1.1"
-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"
+ruff = "^0.1.5"
 
 curlylint = "^0.13.0"
 
@@ -98,10 +87,20 @@ sphinxcontrib-svg2pdfconverter = "^1.1.1"
 sphinx-autodoc-typehints = "^1.7"
 sphinx_material = "^0.0.35"
 
-[tool.black]
+[tool.ruff]
+exclude = ["migrations", "tests"]
 line-length = 100
-exclude = "/migrations/"
 
+[tool.ruff.lint]
+select = ["E", "F", "UP", "B", "SIM", "I", "DJ", "A", "S"]
+ignore = ["UP034", "UP015", "B028"]
+
+[tool.ruff.isort]
+known-first-party = ["aleksis"]
+section-order = ["future", "standard-library", "django", "third-party", "first-party", "local-folder"]
+
+[tool.ruff.isort.sections]
+django = ["django"]
 [build-system]
 requires = ["poetry-core>=1.0.0"]
 build-backend = "poetry.core.masonry.api"
diff --git a/tox.ini b/tox.ini
index 85c2494a5a2f5480bb05d48781edfaa74803eeab..294e65bc96d4e06262b67e3e6b8a987307b226e4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,11 +4,12 @@ skip_missing_interpreters = true
 envlist = py39,py310,py311
 
 [testenv]
-allowlist_externals = poetry
+allowlist_externals =
+    poetry
+    yarnpkg
 skip_install = true
-envdir = {toxworkdir}/globalenv
 commands_pre =
-     poetry install
+     poetry install --all-extras
      poetry run aleksis-admin vite build
      poetry run aleksis-admin collectstatic --no-input
 commands =
@@ -22,14 +23,17 @@ setenv =
     TEST_HOST = {env:TEST_HOST:172.17.0.1}
 
 [testenv:lint]
+commands_pre =
+    poetry install --only=dev
+    yarnpkg --cwd=.dev-js
 commands =
-    poetry run black --check --diff aleksis/
-    poetry run isort -c --diff --stdout aleksis/
-    poetry run flake8 {posargs} aleksis/
-    poetry run sh -c "aleksis-admin yarn run prettier --check --ignore-path={toxinidir}/.prettierignore {toxinidir}"
-    poetry run sh -c "aleksis-admin yarn run eslint {toxinidir}/aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=."
+    poetry run ruff check {posargs} aleksis/
+    yarnpkg --cwd=.dev-js run prettier --ignore-path={toxinidir}/.prettierignore {posargs} --check ..
+    yarnpkg --cwd=.dev-js run eslint ../aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=.
 
 [testenv:security]
+commands_pre =
+    poetry install --all-extras
 commands =
     poetry show --no-dev
     poetry run safety check --full-report
@@ -41,33 +45,25 @@ commands_pre =
 commands = poetry build
 
 [testenv:docs]
+commands_pre =
+    poetry install --with docs
 commands = poetry run make -C docs/ html {posargs}
 
 [testenv:reformat]
+commands_pre =
+    poetry install --only=dev
+    yarnpkg --cwd=.dev-js
 commands =
-    poetry run isort aleksis/
-    poetry run black aleksis/
-    poetry run sh -c "aleksis-admin yarn run prettier --write --ignore-path={toxinidir}/.prettierignore {toxinidir}"
+    poetry run ruff format aleksis/
+    yarnpkg --cwd=.dev-js run prettier --ignore-path={toxinidir}/.prettierignore --write ..
 
 [testenv:makemessages]
+commands_pre =
+    poetry install
 commands =
     poetry run aleksis-admin makemessages --no-wrap -e html,txt,py,email -i static -l ar -l de_DE -l fr -l nb_NO -l tr_TR -l la -l uk -l ru
     poetry run aleksis-admin makemessages --no-wrap -d djangojs -i **/node_modules -l ar -l de_DE -l fr -l nb_NO -l tr_TR -l la -l uk -l ru
 
-[flake8]
-max_line_length = 100
-exclude = migrations,tests
-ignore = BLK100,E203,E231,W503,D100,D101,D102,D103,D104,D105,D106,D107,RST215,RST214,F821,F841,S106,T100,T101,DJ05
-
-[isort]
-profile = black
-line_length = 100
-default_section = THIRDPARTY
-known_first_party = aleksis
-known_django = django
-skip = migrations
-sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
-
 [pytest]
 DJANGO_SETTINGS_MODULE = aleksis.core.settings
 junit_family = legacy