diff --git a/.gitignore b/.gitignore
index c7a8aefaf61fbf7d80eeba081b6192eca60acb32..79b5b76de6f6445254cbf64f7e4fb228ffdf0ab8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,9 @@ docs/_build/
 /static/
 /whoosh_index/
 .vite
+.dev-js/.yarn
+.dev-js/.pnp.cjs
+.dev-js/.pnp.loader.mjs
 
 # Lock files
 poetry.lock
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/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/managers.py b/aleksis/apps/chronos/managers.py
index 486409f95052b51431fcaf74121690dafcb31d7e..73476003eab7821a9d73b326a686d079d85d2417 100644
--- a/aleksis/apps/chronos/managers.py
+++ b/aleksis/apps/chronos/managers.py
@@ -1,6 +1,7 @@
+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
@@ -15,6 +16,9 @@ from aleksis.core.managers import DateRangeQuerySetMixin, SchoolTermRelatedQuery
 from aleksis.core.models import Group, Person
 from aleksis.core.util.core_helpers import get_site_preferences
 
+if TYPE_CHECKING:
+    from .models import Holiday, LessonPeriod, Room, ValidityRange
+
 
 class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
     """Custom query set for validity ranges."""
@@ -402,7 +406,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:
@@ -635,7 +639,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:
diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py
index ed6905004d257c6e89bcf22ef0b87cd1a1a248e5..a4bf3831063fd76bf20bd9da681b00973de9000f 100644
--- a/aleksis/apps/chronos/models.py
+++ b/aleksis/apps/chronos/models.py
@@ -2,22 +2,19 @@
 
 from __future__ import annotations
 
-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.validators import MinValueValidator
 from django.db import models
 from django.db.models import Max, Min, Q
 from django.db.models.functions import Coalesce
 from django.dispatch import receiver
 from django.forms import Media
-from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.formats import date_format
@@ -26,8 +23,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
@@ -69,7 +64,6 @@ from aleksis.core.mixins import (
 )
 from aleksis.core.models import DashboardWidget, Group, Room, SchoolTerm
 from aleksis.core.util.core_helpers import has_person
-from aleksis.core.util.pdf import generate_pdf_from_template
 
 
 class ValidityRange(ExtensibleModel):
@@ -93,7 +87,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:
@@ -110,12 +104,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:
@@ -158,14 +151,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:
@@ -176,37 +169,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)
 
@@ -226,7 +208,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()
@@ -236,15 +218,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))
@@ -255,7 +237,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.
@@ -282,12 +264,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
@@ -310,7 +292,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)
@@ -449,7 +431,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:
@@ -524,7 +506,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
@@ -535,7 +517,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
@@ -549,7 +531,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:
@@ -578,7 +560,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::
@@ -587,7 +569,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::
@@ -596,7 +578,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]))
@@ -683,7 +665,7 @@ 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)
+    name = models.CharField(verbose_name=_("Name"), blank=True, max_length=255)
 
     def __str__(self):
         if self.name:
@@ -754,7 +736,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:
@@ -813,7 +795,7 @@ class Holiday(ExtensibleModel):
     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
@@ -821,7 +803,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]
@@ -829,7 +811,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)
 
@@ -920,7 +902,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_
@@ -972,9 +954,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
@@ -1048,7 +1028,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)
@@ -1219,7 +1199,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",
@@ -1293,7 +1273,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
 
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/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 17d98c54a88e28c9be5ca81d8561924d4155c83a..26b2f13052f8823e9c3152980d59c40ff8879d20 100644
--- a/aleksis/apps/chronos/util/chronos_helpers.py
+++ b/aleksis/apps/chronos/util/chronos_helpers.py
@@ -190,7 +190,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:
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/package.json b/package.json
deleted file mode 100644
index 55795327ff2174e7bfa6778a26173717367abd5a..0000000000000000000000000000000000000000
--- a/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-  "name": "aleksis-builddeps",
-  "version": "1.0.0",
-  "dependencies": {
-    "@intlify/eslint-plugin-vue-i18n": "^2.0.0",
-    "eslint": "^8.26.0",
-    "eslint-config-prettier": "^8.5.0",
-    "eslint-plugin-vue": "^9.7.0",
-    "prettier": "^2.8.1",
-    "stylelint": "^14.14.0",
-    "stylelint-config-prettier": "^9.0.3",
-    "stylelint-config-standard": "^29.0.0"
-  }
-}
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