diff --git a/aleksis/apps/chronos/admin.py b/aleksis/apps/chronos/admin.py
index f2fefcd458f610f8cfd265e5b1ed6af8ffae1d6d..5145472fdbf9eff3349c81d0cbf10a59c24442f2 100644
--- a/aleksis/apps/chronos/admin.py
+++ b/aleksis/apps/chronos/admin.py
@@ -20,6 +20,7 @@ from .models import (
     SupervisionSubstitution,
     TimePeriod,
     TimetableWidget,
+    ValidityRange,
 )
 from .util.format import format_date_period, format_m2m
 
@@ -201,3 +202,11 @@ class TimetableWidgetAdmin(admin.ModelAdmin):
 
 
 admin.site.register(TimetableWidget, TimetableWidgetAdmin)
+
+
+class ValidityRangeAdmin(admin.ModelAdmin):
+    list_display = ("__str__", "date_start", "date_end")
+    list_display_links = ("__str__", "date_start", "date_end")
+
+
+admin.site.register(ValidityRange, ValidityRangeAdmin)
diff --git a/aleksis/apps/chronos/managers.py b/aleksis/apps/chronos/managers.py
index 361f8da29cdeecf23ff80fc14ea722f9623d52c1..212a70f0ffaa00812d46f055857f0bddaaea3c32 100644
--- a/aleksis/apps/chronos/managers.py
+++ b/aleksis/apps/chronos/managers.py
@@ -4,15 +4,67 @@ from typing import Optional, Union
 
 from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager
 from django.db import models
-from django.db.models import Count, F, Q
+from django.db.models import Count, F, Q, QuerySet
 
 from calendarweek import CalendarWeek
 
 from aleksis.apps.chronos.util.date import week_weekday_from_date
+from aleksis.core.managers import DateRangeQuerySetMixin, SchoolTermRelatedQuerySet
 from aleksis.core.models import Group, Person
 from aleksis.core.util.core_helpers import get_site_preferences
 
 
+class ValidityRangeQuerySet(QuerySet, DateRangeQuerySetMixin):
+    """Custom query set for validity ranges."""
+
+
+class ValidityRangeRelatedQuerySet(QuerySet):
+    """Custom query set for all models related to validity ranges."""
+
+    def within_dates(self, start: date, end: date) -> "ValidityRangeRelatedQuerySet":
+        """Filter for all objects within a date range."""
+        return self.filter(validity__date_start__lte=end, validity__date_end__gte=start)
+
+    def in_week(self, wanted_week: CalendarWeek) -> "ValidityRangeRelatedQuerySet":
+        """Filter for all objects within a calendar week."""
+        return self.within_dates(wanted_week[0], wanted_week[6])
+
+    def on_day(self, day: date) -> "ValidityRangeRelatedQuerySet":
+        """Filter for all objects on a certain day."""
+        return self.within_dates(day, day)
+
+    def for_validity_range(
+        self, validity_range: "ValidityRange"
+    ) -> "ValidityRangeRelatedQuerySet":
+        return self.filter(validity_range=validity_range)
+
+    def for_current_or_all(self) -> "ValidityRangeRelatedQuerySet":
+        """Get all objects related to current validity range.
+
+        If there is no current validity range, it will return all objects.
+        """
+        from aleksis.apps.chronos.models import ValidityRange
+
+        current_validity_range = ValidityRange.current
+        if current_validity_range:
+            return self.for_validity_range(current_validity_range)
+        else:
+            return self
+
+    def for_current_or_none(self) -> Union["ValidityRangeRelatedQuerySet", None]:
+        """Get all objects related to current validity range.
+
+        If there is no current validity range, it will return `None`.
+        """
+        from aleksis.apps.chronos.models import ValidityRange
+
+        current_validity_range = ValidityRange.current
+        if current_validity_range:
+            return self.for_validity_range(current_validity_range)
+        else:
+            return None
+
+
 class CurrentSiteManager(_CurrentSiteManager):
     use_in_migrations = False
 
@@ -108,8 +160,8 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
         """Filter for all lessons within a date range."""
         return self.filter(
             **{
-                self._period_path + "lesson__date_start__lte": start,
-                self._period_path + "lesson__date_end__gte": end,
+                self._period_path + "lesson__validity__date_start__lte": start,
+                self._period_path + "lesson__validity__date_end__gte": end,
             }
         )
 
@@ -137,8 +189,8 @@ class LessonDataQuerySet(models.QuerySet, WeekQuerySetMixin):
 
         return self.filter(
             **{
-                self._period_path + "lesson__date_start__lte": now.date(),
-                self._period_path + "lesson__date_end__gte": now.date(),
+                self._period_path + "lesson__validity__date_start__lte": now.date(),
+                self._period_path + "lesson__validity__date_end__gte": now.date(),
                 self._period_path + "period__weekday": now.weekday(),
                 self._period_path + "period__time_start__lte": now.time(),
                 self._period_path + "period__time_end__gte": now.time(),
@@ -282,7 +334,7 @@ class LessonSubstitutionQuerySet(LessonDataQuerySet):
         )
 
 
-class DateRangeQuerySet(models.QuerySet):
+class DateRangeQuerySetMixin:
     """QuerySet with custom query methods for models with date and period ranges.
 
     Filterable fields: date_start, date_end, period_from, period_to
@@ -309,7 +361,7 @@ class DateRangeQuerySet(models.QuerySet):
         )
 
 
-class AbsenceQuerySet(DateRangeQuerySet):
+class AbsenceQuerySet(SchoolTermRelatedQuerySet, DateRangeQuerySetMixin):
     """QuerySet with custom query methods for absences."""
 
     def absent_teachers(self):
@@ -322,13 +374,13 @@ class AbsenceQuerySet(DateRangeQuerySet):
         return Person.objects.filter(absences__in=self).annotate(absences_count=Count("absences"))
 
 
-class HolidayQuerySet(DateRangeQuerySet):
+class HolidayQuerySet(QuerySet, DateRangeQuerySetMixin):
     """QuerySet with custom query methods for holidays."""
 
     pass
 
 
-class SupervisionQuerySet(models.QuerySet, WeekQuerySetMixin):
+class SupervisionQuerySet(ValidityRangeRelatedQuerySet, WeekQuerySetMixin):
     """QuerySet with custom query methods for supervisions."""
 
     def filter_by_weekday(self, weekday: int):
@@ -423,7 +475,9 @@ class TimetableQuerySet(models.QuerySet):
             return None
 
 
-class EventQuerySet(DateRangeQuerySet, TimetableQuerySet):
+class EventQuerySet(
+    SchoolTermRelatedQuerySet, DateRangeQuerySetMixin, TimetableQuerySet
+):
     """QuerySet with custom query methods for events."""
 
     def annotate_day(self, day: date):
@@ -431,7 +485,9 @@ class EventQuerySet(DateRangeQuerySet, TimetableQuerySet):
         return self.annotate(_date=models.Value(day, models.DateField()))
 
 
-class ExtraLessonQuerySet(TimetableQuerySet, GroupByPeriodsMixin):
+class ExtraLessonQuerySet(
+    SchoolTermRelatedQuerySet, TimetableQuerySet, GroupByPeriodsMixin
+):
     """QuerySet with custom query methods for extra lessons."""
 
     _multiple_rooms = False
diff --git a/aleksis/apps/chronos/mixins.py b/aleksis/apps/chronos/mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..dabaabf6550210088f2d4481b62612f6130ef814
--- /dev/null
+++ b/aleksis/apps/chronos/mixins.py
@@ -0,0 +1,27 @@
+from django.db import models
+from django.utils.translation import gettext as _
+
+from aleksis.core.managers import CurrentSiteManagerWithoutMigrations
+from aleksis.core.mixins import ExtensibleModel
+
+from .managers import ValidityRangeRelatedQuerySet
+
+
+class ValidityRangeRelatedExtensibleModel(ExtensibleModel):
+    """Add relation to validity range."""
+
+    objects = CurrentSiteManagerWithoutMigrations.from_queryset(
+        ValidityRangeRelatedQuerySet
+    )()
+
+    validity = models.ForeignKey(
+        "chronos.ValidityRange",
+        on_delete=models.CASCADE,
+        related_name="+",
+        verbose_name=_("Linked validity range"),
+        null=True,
+        blank=True,
+    )
+
+    class Meta:
+        abstract = True
diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py
index 025d83ddfc73b409d11418013b2ea814d6cadab6..2883803533f495aa4cd68a9dc505ed9a8d9c6b58 100644
--- a/aleksis/apps/chronos/models.py
+++ b/aleksis/apps/chronos/models.py
@@ -33,14 +33,86 @@ from aleksis.apps.chronos.managers import (
     LessonSubstitutionQuerySet,
     SupervisionQuerySet,
     TeacherPropertiesMixin,
+    ValidityRangeQuerySet,
 )
+from aleksis.apps.chronos.mixins import ValidityRangeRelatedExtensibleModel
 from aleksis.apps.chronos.util.format import format_m2m
-from aleksis.core.mixins import ExtensibleModel
+from aleksis.core.managers import CurrentSiteManagerWithoutMigrations
+from aleksis.core.mixins import ExtensibleModel, SchoolTermRelatedExtensibleModel
 from aleksis.core.models import DashboardWidget, SchoolTerm
 from aleksis.core.util.core_helpers import has_person
 
 
-class TimePeriod(ExtensibleModel):
+class ValidityRange(ExtensibleModel):
+    """Validity range model.
+
+    This is used to link data to a validity range.
+    """
+
+    objects = CurrentSiteManagerWithoutMigrations.from_queryset(ValidityRangeQuerySet)()
+
+    school_term = models.ForeignKey(
+        SchoolTerm,
+        on_delete=models.CASCADE,
+        verbose_name=_("School term"),
+        related_name="validity_ranges",
+    )
+    name = models.CharField(verbose_name=_("Name"), max_length=255, blank=True)
+
+    date_start = models.DateField(verbose_name=_("Start date"))
+    date_end = models.DateField(verbose_name=_("End date"))
+
+    @classmethod
+    def get_current(cls, day: Optional[date] = None):
+        if not day:
+            day = timezone.now().date()
+        try:
+            return cls.objects.on_day(day).first()
+        except ValidityRange.DoesNotExist:
+            return None
+
+    @classproperty
+    def current(cls):
+        return cls.get_current()
+
+    def clean(self):
+        """Ensure there is only one validity range at each point of time."""
+        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.")
+                )
+
+        qs = ValidityRange.objects.within_dates(self.date_start, self.date_end)
+        if self.pk:
+            qs.exclude(pk=self.pk)
+        if qs.exists():
+            raise ValidationError(
+                _(
+                    "There is already a validity range for this time or a part of this time."
+                )
+            )
+
+    def __str__(self):
+        return (
+            self.name or f"{date_format(self.date_start)}–{date_format(self.date_end)}"
+        )
+
+    class Meta:
+        verbose_name = _("Validity range")
+        verbose_name_plural = _("Validity ranges")
+        unique_together = ["date_start", "date_end"]
+
+
+class TimePeriod(ValidityRangeRelatedExtensibleModel):
     WEEKDAY_CHOICES = list(enumerate(i18n_day_names_lazy()))
     WEEKDAY_CHOICES_SHORT = list(enumerate(i18n_day_abbrs_lazy()))
 
@@ -56,7 +128,7 @@ class TimePeriod(ExtensibleModel):
     @classmethod
     def get_times_dict(cls) -> Dict[int, Tuple[datetime, datetime]]:
         periods = {}
-        for period in cls.objects.all():
+        for period in cls.objects.for_current_or_all().all():
             periods[period.period] = (period.time_start, period.time_end)
 
         return periods
@@ -127,27 +199,51 @@ class TimePeriod(ExtensibleModel):
 
     @classproperty
     def period_min(cls) -> int:
-        return cls.objects.aggregate(period__min=Coalesce(Min("period"), 1)).get("period__min")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(period__min=Coalesce(Min("period"), 1))
+            .get("period__min")
+        )
 
     @classproperty
     def period_max(cls) -> int:
-        return cls.objects.aggregate(period__max=Coalesce(Max("period"), 7)).get("period__max")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(period__max=Coalesce(Max("period"), 7))
+            .get("period__max")
+        )
 
     @classproperty
     def time_min(cls) -> Optional[time]:
-        return cls.objects.aggregate(Min("time_start")).get("time_start__min")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(Min("time_start"))
+            .get("time_start__min")
+        )
 
     @classproperty
     def time_max(cls) -> Optional[time]:
-        return cls.objects.aggregate(Max("time_end")).get("time_end__max")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(Max("time_end"))
+            .get("time_end__max")
+        )
 
     @classproperty
     def weekday_min(cls) -> int:
-        return cls.objects.aggregate(weekday__min=Coalesce(Min("weekday"), 0)).get("weekday__min")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(weekday__min=Coalesce(Min("weekday"), 0))
+            .get("weekday__min")
+        )
 
     @classproperty
     def weekday_max(cls) -> int:
-        return cls.objects.aggregate(weekday__max=Coalesce(Max("weekday"), 6)).get("weekday__max")
+        return (
+            cls.objects.for_current_or_all()
+            .aggregate(weekday__max=Coalesce(Max("weekday"), 6))
+            .get("weekday__max")
+        )
 
     class Meta:
         unique_together = [["weekday", "period"]]
@@ -186,7 +282,9 @@ class Room(ExtensibleModel):
         verbose_name_plural = _("Rooms")
 
 
-class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
+class Lesson(
+    ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin
+):
     subject = models.ForeignKey(
         "Subject", on_delete=models.CASCADE, related_name="lessons", verbose_name=_("Subject"),
     )
@@ -198,13 +296,14 @@ class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
     )
     groups = models.ManyToManyField("core.Group", related_name="lessons", verbose_name=_("Groups"))
 
-    date_start = models.DateField(verbose_name=_("Start date"), null=True)
-    date_end = models.DateField(verbose_name=_("End date"), null=True)
+    def get_year(self, week: int) -> int:
+        year = self.validity.date_start.year
+        if week < int(self.validity.date_start.strftime("%V")):
+            year += 1
+        return year
 
     def get_calendar_week(self, week: int):
-        year = self.date_start.year
-        if week < int(self.date_start.strftime("%V")):
-            year += 1
+        year = self.get_year(week)
 
         return CalendarWeek(year=year, week=week)
 
@@ -212,8 +311,7 @@ class Lesson(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
         return f"{format_m2m(self.groups)}, {self.subject.short_name}, {format_m2m(self.teachers)}"
 
     class Meta:
-        ordering = ["date_start", "subject"]
-        indexes = [models.Index(fields=["date_start", "date_end"])]
+        ordering = ["validity__date_start", "subject"]
         verbose_name = _("Lesson")
         verbose_name_plural = _("Lessons")
 
@@ -253,7 +351,7 @@ class LessonSubstitution(ExtensibleModel):
 
     @property
     def date(self):
-        week = CalendarWeek(week=self.week)
+        week = CalendarWeek(week=self.week, year=self.lesson_period.lesson.get_year())
         return week[self.lesson_period.period.weekday]
 
     def __str__(self):
@@ -262,7 +360,7 @@ class LessonSubstitution(ExtensibleModel):
     class Meta:
         unique_together = [["lesson_period", "week"]]
         ordering = [
-            "lesson_period__lesson__date_start",
+            "lesson_period__lesson__validity__date_start",
             "week",
             "lesson_period__period__weekday",
             "lesson_period__period__period",
@@ -362,7 +460,7 @@ class LessonPeriod(ExtensibleModel):
 
     class Meta:
         ordering = [
-            "lesson__date_start",
+            "lesson__validity__date_start",
             "period__weekday",
             "period__period",
             "lesson__subject",
@@ -427,7 +525,7 @@ class AbsenceReason(ExtensibleModel):
         verbose_name_plural = _("Absence reasons")
 
 
-class Absence(ExtensibleModel):
+class Absence(SchoolTermRelatedExtensibleModel):
     objects = CurrentSiteManager.from_queryset(AbsenceQuerySet)()
 
     reason = models.ForeignKey(
@@ -499,7 +597,7 @@ class Absence(ExtensibleModel):
         verbose_name_plural = _("Absences")
 
 
-class Exam(ExtensibleModel):
+class Exam(SchoolTermRelatedExtensibleModel):
     lesson = models.ForeignKey(
         "Lesson", on_delete=models.CASCADE, related_name="exams", verbose_name=_("Lesson"),
     )
@@ -583,7 +681,7 @@ class SupervisionArea(ExtensibleModel):
         verbose_name_plural = _("Supervision areas")
 
 
-class Break(ExtensibleModel):
+class Break(ValidityRangeRelatedExtensibleModel):
     short_name = models.CharField(verbose_name=_("Short name"), max_length=255)
     name = models.CharField(verbose_name=_("Long name"), max_length=255)
 
@@ -642,7 +740,7 @@ class Break(ExtensibleModel):
         verbose_name_plural = _("Breaks")
 
 
-class Supervision(ExtensibleModel):
+class Supervision(ValidityRangeRelatedExtensibleModel):
     objects = CurrentSiteManager.from_queryset(SupervisionQuerySet)()
 
     area = models.ForeignKey(
@@ -708,7 +806,9 @@ class SupervisionSubstitution(ExtensibleModel):
         verbose_name_plural = _("Supervision substitutions")
 
 
-class Event(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
+class Event(
+    SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin
+):
     label_ = "event"
 
     objects = CurrentSiteManager.from_queryset(EventQuerySet)()
@@ -763,7 +863,7 @@ class Event(ExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
         verbose_name_plural = _("Events")
 
 
-class ExtraLesson(ExtensibleModel, GroupPropertiesMixin):
+class ExtraLesson(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin):
     label_ = "extra_lesson"
 
     objects = CurrentSiteManager.from_queryset(ExtraLessonQuerySet)()
diff --git a/aleksis/apps/chronos/util/build.py b/aleksis/apps/chronos/util/build.py
index a5290ebbbf2a18013acb98cf17b6be57752847cd..89b32ae3fdb60adedcfe582bfde8d0147677533b 100644
--- a/aleksis/apps/chronos/util/build.py
+++ b/aleksis/apps/chronos/util/build.py
@@ -124,7 +124,12 @@ def build_timetable(
             week = CalendarWeek.from_date(date_ref)
         else:
             week = date_ref
-        supervisions = Supervision.objects.all().annotate_week(week).filter_by_teacher(obj)
+        supervisions = (
+            Supervision.objects.in_week(week)
+            .all()
+            .annotate_week(week)
+            .filter_by_teacher(obj)
+        )
 
         if is_person:
             supervisions.filter_by_weekday(date_ref.weekday())