diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py
index abd45aabdee3f9d933376a0fc034f59218a8523f..208cbff93f76b7d44451550b73d3b5659ba4aa59 100644
--- a/aleksis/core/forms.py
+++ b/aleksis/core/forms.py
@@ -9,8 +9,8 @@ from django_select2.forms import ModelSelect2MultipleWidget, Select2Widget
 from dynamic_preferences.forms import PreferenceForm
 from material import Fieldset, Layout, Row
 
-from .mixins import ExtensibleForm
-from .models import AdditionalField, Announcement, Group, GroupType, Person
+from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm
+from .models import AdditionalField, Announcement, Group, GroupType, Person, SchoolTerm
 from .registries import (
     group_preferences_registry,
     person_preferences_registry,
@@ -121,10 +121,11 @@ class EditPersonForm(ExtensibleForm):
         return PersonAccountForm.clean(self)
 
 
-class EditGroupForm(ExtensibleForm):
+class EditGroupForm(SchoolTermRelatedExtensibleForm):
     """Form to edit an existing group in the frontend."""
 
     layout = Layout(
+        Fieldset(_("School term"), "school_term"),
         Fieldset(_("Common data"), "name", "short_name", "group_type"),
         Fieldset(_("Persons"), "members", "owners", "parent_groups"),
         Fieldset(_("Additional data"), "additional_fields"),
@@ -170,7 +171,7 @@ class AnnouncementForm(ExtensibleForm):
     persons = forms.ModelMultipleChoiceField(
         Person.objects.all(), label=_("Persons"), required=False
     )
-    groups = forms.ModelMultipleChoiceField(Group.objects.all(), label=_("Groups"), required=False)
+    groups = forms.ModelMultipleChoiceField(queryset=None, label=_("Groups"), required=False)
 
     layout = Layout(
         Fieldset(
@@ -205,6 +206,8 @@ class AnnouncementForm(ExtensibleForm):
 
         super().__init__(*args, **kwargs)
 
+        self.fields["groups"].queryset = Group.objects.for_current_school_term_or_all()
+
     def clean(self):
         data = super().clean()
 
@@ -298,3 +301,13 @@ class EditGroupTypeForm(forms.ModelForm):
     class Meta:
         model = GroupType
         exclude = []
+
+
+class SchoolTermForm(ExtensibleForm):
+    """Form for managing school years."""
+
+    layout = Layout("name", Row("date_start", "date_end"))
+
+    class Meta:
+        model = SchoolTerm
+        exclude = []
diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0871078015df8e117a09e23a593b384f7b25dc2
--- /dev/null
+++ b/aleksis/core/managers.py
@@ -0,0 +1,81 @@
+from datetime import date
+from typing import Union
+
+from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager
+from django.db.models import QuerySet
+
+from calendarweek import CalendarWeek
+
+
+class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager):
+    """CurrentSiteManager for auto-generating managers just by query sets."""
+
+    use_in_migrations = False
+
+
+class DateRangeQuerySetMixin:
+    """QuerySet with custom query methods for models with date ranges.
+
+    Filterable fields: date_start, date_end
+    """
+
+    def within_dates(self, start: date, end: date):
+        """Filter for all objects within a date range."""
+        return self.filter(date_start__lte=end, date_end__gte=start)
+
+    def in_week(self, wanted_week: CalendarWeek):
+        """Filter for all objects within a calendar week."""
+        return self.within_dates(wanted_week[0], wanted_week[6])
+
+    def on_day(self, day: date):
+        """Filter for all objects on a certain day."""
+        return self.within_dates(day, day)
+
+
+class SchoolTermQuerySet(QuerySet, DateRangeQuerySetMixin):
+    """Custom query set for school terms."""
+
+
+class SchoolTermRelatedQuerySet(QuerySet):
+    """Custom query set for all models related to school terms."""
+
+    def within_dates(self, start: date, end: date) -> "SchoolTermRelatedQuerySet":
+        """Filter for all objects within a date range."""
+        return self.filter(school_term__date_start__lte=end, school_term__date_end__gte=start)
+
+    def in_week(self, wanted_week: CalendarWeek) -> "SchoolTermRelatedQuerySet":
+        """Filter for all objects within a calendar week."""
+        return self.within_dates(wanted_week[0], wanted_week[6])
+
+    def on_day(self, day: date) -> "SchoolTermRelatedQuerySet":
+        """Filter for all objects on a certain day."""
+        return self.within_dates(day, day)
+
+    def for_school_term(self, school_term: "SchoolTerm") -> "SchoolTermRelatedQuerySet":
+        return self.filter(school_term=school_term)
+
+    def for_current_school_term_or_all(self) -> "SchoolTermRelatedQuerySet":
+        """Get all objects related to current school term.
+
+        If there is no current school term, it will return all objects.
+        """
+        from aleksis.core.models import SchoolTerm
+
+        current_school_term = SchoolTerm.current
+        if current_school_term:
+            return self.for_school_term(current_school_term)
+        else:
+            return self
+
+    def for_current_school_term_or_none(self) -> Union["SchoolTermRelatedQuerySet", None]:
+        """Get all objects related to current school term.
+
+        If there is no current school term, it will return `None`.
+        """
+        from aleksis.core.models import SchoolTerm
+
+        current_school_term = SchoolTerm.current
+        if current_school_term:
+            return self.for_school_term(current_school_term)
+        else:
+            return None
diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py
index c1b19bb3970eac18d756ec0eae6bdaf8ae285d07..2e71c357384c1bc7e4ffa4ff1485f4edc4ffc7de 100644
--- a/aleksis/core/menus.py
+++ b/aleksis/core/menus.py
@@ -82,6 +82,17 @@ MENUS = {
                         ),
                     ],
                 },
+                {
+                    "name": _("School terms"),
+                    "url": "school_terms",
+                    "icon": "date_range",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.view_schoolterm",
+                        ),
+                    ],
+                },
                 {
                     "name": _("Data management"),
                     "url": "data_management",
diff --git a/aleksis/core/migrations/0002_school_term.py b/aleksis/core/migrations/0002_school_term.py
new file mode 100644
index 0000000000000000000000000000000000000000..456f78ebf7e16f32b92b954d36ad39f5d323e866
--- /dev/null
+++ b/aleksis/core/migrations/0002_school_term.py
@@ -0,0 +1,64 @@
+# Generated by Django 3.0.7 on 2020-06-13 14:34
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('core', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SchoolTerm',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict, editable=False)),
+                ('name', models.CharField(max_length=255, unique=True, verbose_name='Name')),
+                ('date_start', models.DateField(verbose_name='Start date')),
+                ('date_end', models.DateField(verbose_name='End date')),
+            ],
+            options={
+                'verbose_name': 'School term',
+                'verbose_name_plural': 'School terms',
+            },
+        ),
+        migrations.AlterModelManagers(
+            name='group',
+            managers=[
+            ],
+        ),
+        migrations.AlterField(
+            model_name='group',
+            name='name',
+            field=models.CharField(max_length=255, verbose_name='Long name'),
+        ),
+        migrations.AlterField(
+            model_name='group',
+            name='short_name',
+            field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Short name'),
+        ),
+        migrations.AddField(
+            model_name='group',
+            name='school_term',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='+', to='core.SchoolTerm', verbose_name='Linked school term'),
+        ),
+        migrations.AddConstraint(
+            model_name='group',
+            constraint=models.UniqueConstraint(fields=('school_term', 'name'), name='unique_school_term_name'),
+        ),
+        migrations.AddConstraint(
+            model_name='group',
+            constraint=models.UniqueConstraint(fields=('school_term', 'short_name'), name='unique_school_term_short_name'),
+        ),
+        migrations.AddField(
+            model_name='schoolterm',
+            name='site',
+            field=models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.Site'),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 6b52fb3820c366c1040905eed1226cdc1d4c0e3f..8bff9408655273aaad6b05901f8f1dd3e38706c1 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -4,13 +4,19 @@ from datetime import datetime
 from typing import Any, Callable, List, Optional, Tuple, Union
 
 from django.conf import settings
+from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.sites.managers import CurrentSiteManager
 from django.contrib.sites.models import Site
 from django.db import models
 from django.db.models import QuerySet
+from django.forms.forms import BaseForm
 from django.forms.models import ModelForm, ModelFormMetaclass
+from django.http import HttpResponse
 from django.utils.functional import lazy
+from django.utils.translation import gettext as _
+from django.views.generic import CreateView, UpdateView
+from django.views.generic.edit import ModelFormMixin
 
 import reversion
 from easyaudit.models import CRUDEvent
@@ -19,6 +25,8 @@ from jsonstore.fields import JSONField, JSONFieldMixin
 from material.base import Layout, LayoutNode
 from rules.contrib.admin import ObjectPermissionsModelAdmin
 
+from aleksis.core.managers import CurrentSiteManagerWithoutMigrations, SchoolTermRelatedQuerySet
+
 
 class _ExtensibleModelBase(models.base.ModelBase):
     """Ensure predefined behaviour on model creation.
@@ -279,3 +287,55 @@ class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin):
     """A base class for ModelAdmin combining django-guardian and rules."""
 
     pass
+
+
+class SuccessMessageMixin(ModelFormMixin):
+    success_message: Optional[str] = None
+
+    def form_valid(self, form: BaseForm) -> HttpResponse:
+        if self.success_message:
+            messages.success(self.request, self.success_message)
+        return super().form_valid(form)
+
+
+class AdvancedCreateView(CreateView, SuccessMessageMixin):
+    pass
+
+
+class AdvancedEditView(UpdateView, SuccessMessageMixin):
+    pass
+
+
+class SchoolTermRelatedExtensibleModel(ExtensibleModel):
+    """Add relation to school term."""
+
+    objects = CurrentSiteManagerWithoutMigrations.from_queryset(SchoolTermRelatedQuerySet)()
+
+    school_term = models.ForeignKey(
+        "core.SchoolTerm",
+        on_delete=models.CASCADE,
+        related_name="+",
+        verbose_name=_("Linked school term"),
+        blank=True,
+        null=True,
+    )
+
+    class Meta:
+        abstract = True
+
+
+class SchoolTermRelatedExtensibleForm(ExtensibleForm):
+    """Extensible form for school term related data.
+
+    .. warning::
+        This doesn't automatically include the field `school_term` in `fields` or `layout`,
+        it just sets an initial value.
+    """
+
+    def __init__(self, *args, **kwargs):
+        from aleksis.core.models import SchoolTerm  # noqa
+
+        if "instance" not in kwargs:
+            kwargs["initial"] = {"school_term": SchoolTerm.current}
+
+        super().__init__(*args, **kwargs)
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index fe0d63d62713fbdfa6ff9b142844d1226c28be6f..0deffed47c9970ab1ab083fa049e90c1960fe6b8 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -8,11 +8,13 @@ from django.contrib.auth.models import Group as DjangoGroup
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.sites.models import Site
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db.models import QuerySet
 from django.forms.widgets import Media
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.decorators import classproperty
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 
@@ -22,7 +24,8 @@ from image_cropping import ImageCropField, ImageRatioField
 from phonenumber_field.modelfields import PhoneNumberField
 from polymorphic.models import PolymorphicModel
 
-from .mixins import ExtensibleModel, PureDjangoModel
+from .managers import CurrentSiteManagerWithoutMigrations, SchoolTermQuerySet
+from .mixins import ExtensibleModel, PureDjangoModel, SchoolTermRelatedExtensibleModel
 from .tasks import send_notification
 from .util.core_helpers import get_site_preferences, now_tomorrow
 from .util.model_helpers import ICONS
@@ -43,6 +46,53 @@ FIELD_CHOICES = (
 )
 
 
+class SchoolTerm(ExtensibleModel):
+    """School term model.
+
+    This is used to manage start and end times of a school term and link data to it.
+    """
+
+    objects = CurrentSiteManagerWithoutMigrations.from_queryset(SchoolTermQuerySet)()
+
+    name = models.CharField(verbose_name=_("Name"), max_length=255, unique=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 SchoolTerm.DoesNotExist:
+            return None
+
+    @classproperty
+    def current(cls):
+        return cls.get_current()
+
+    def clean(self):
+        """Ensure there is only one school term at each point of time."""
+        if self.date_end < self.date_start:
+            raise ValidationError(_("The start date must be earlier than the end date."))
+
+        qs = SchoolTerm.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 school term for this time or a part of this time.")
+            )
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        verbose_name = _("School term")
+        verbose_name_plural = _("School terms")
+
+
 class Person(ExtensibleModel):
     """Person model.
 
@@ -255,7 +305,7 @@ class AdditionalField(ExtensibleModel):
         verbose_name_plural = _("Addtitional fields for groups")
 
 
-class Group(ExtensibleModel):
+class Group(SchoolTermRelatedExtensibleModel):
     """Group model.
 
     Any kind of group of persons in a school, including, but not limited
@@ -267,12 +317,18 @@ class Group(ExtensibleModel):
         verbose_name = _("Group")
         verbose_name_plural = _("Groups")
         permissions = (("assign_child_groups_to_groups", _("Can assign child groups to groups")),)
+        constraints = [
+            models.UniqueConstraint(fields=["school_term", "name"], name="unique_school_term_name"),
+            models.UniqueConstraint(
+                fields=["school_term", "short_name"], name="unique_school_term_short_name"
+            ),
+        ]
 
     icon_ = "group"
 
-    name = models.CharField(verbose_name=_("Long name"), max_length=255, unique=True)
+    name = models.CharField(verbose_name=_("Long name"), max_length=255)
     short_name = models.CharField(
-        verbose_name=_("Short name"), max_length=255, unique=True, blank=True, null=True  # noqa
+        verbose_name=_("Short name"), max_length=255, blank=True, null=True  # noqa
     )
 
     members = models.ManyToManyField(
@@ -315,7 +371,10 @@ class Group(ExtensibleModel):
         return list(self.members.all()) + list(self.owners.all())
 
     def __str__(self) -> str:
-        return f"{self.name} ({self.short_name})"
+        if self.school_term:
+            return f"{self.name} ({self.short_name}) ({self.school_term})"
+        else:
+            return f"{self.name} ({self.short_name})"
 
     def save(self, *args, **kwargs):
         super().save(*args, **kwargs)
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index 4f97eafddf3cfdb9725cf0e9e0cde05ef83d1ad0..e854c3077dd60e2cf7bd22e02dcd0240689876b3 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -98,14 +98,6 @@ add_perm("core.assign_child_groups_to_groups", assign_child_groups_to_groups_pre
 edit_school_information_predicate = has_person & has_global_perm("core.change_school")
 add_perm("core.edit_school_information", edit_school_information_predicate)
 
-# Edit school term
-edit_schoolterm_predicate = has_person & has_global_perm("core.change_schoolterm")
-add_perm("core.edit_schoolterm", edit_schoolterm_predicate)
-
-# Manage school
-manage_school_predicate = edit_school_information_predicate | edit_schoolterm_predicate
-add_perm("core.manage_school", manage_school_predicate)
-
 # Manage data
 manage_data_predicate = has_person & has_global_perm("core.manage_data")
 add_perm("core.manage_data", manage_data_predicate)
@@ -154,16 +146,6 @@ add_perm(
     ),
 )
 
-# View admin menu
-view_admin_menu_predicate = has_person & (
-    manage_data_predicate
-    | manage_school_predicate
-    | impersonate_predicate
-    | view_system_status_predicate
-    | view_announcements_predicate
-)
-add_perm("core.view_admin_menu", view_admin_menu_predicate)
-
 # View person personal details
 view_personal_details_predicate = has_person & (
     has_global_perm("core.view_personal_details")
@@ -246,3 +228,23 @@ view_group_type_predicate = has_person & (
     has_global_perm("core.view_grouptype") | has_any_object("core.view_grouptype", GroupType)
 )
 add_perm("core.view_grouptype", view_group_type_predicate)
+
+# School years
+view_school_term_predicate = has_person & has_global_perm("core.view_schoolterm")
+add_perm("core.view_schoolterm", view_school_term_predicate)
+
+create_school_term_predicate = has_person & has_global_perm("core.add_schoolterm")
+add_perm("core.create_schoolterm", create_school_term_predicate)
+
+edit_school_term_predicate = has_person & has_global_perm("core.change_schoolterm")
+add_perm("core.edit_schoolterm", edit_school_term_predicate)
+
+# View admin menu
+view_admin_menu_predicate = has_person & (
+    manage_data_predicate
+    | view_school_term_predicate
+    | impersonate_predicate
+    | view_system_status_predicate
+    | view_announcements_predicate
+)
+add_perm("core.view_admin_menu", view_admin_menu_predicate)
diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py
index 6f7ba041457f11420cf9155ff0cc8ca5012fd9d1..f562b61a151487c5047ef2861255a00ef7a5f785 100644
--- a/aleksis/core/tables.py
+++ b/aleksis/core/tables.py
@@ -4,6 +4,24 @@ import django_tables2 as tables
 from django_tables2.utils import A
 
 
+class SchoolTermTable(tables.Table):
+    """Table to list persons."""
+
+    class Meta:
+        attrs = {"class": "responsive-table highlight"}
+
+    name = tables.LinkColumn("edit_school_term", args=[A("id")])
+    date_start = tables.Column()
+    date_end = tables.Column()
+    edit = tables.LinkColumn(
+        "edit_school_term",
+        args=[A("id")],
+        text=_("Edit"),
+        attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}},
+        verbose_name=_("Actions"),
+    )
+
+
 class PersonsTable(tables.Table):
     """Table to list persons."""
 
@@ -22,6 +40,7 @@ class GroupsTable(tables.Table):
 
     name = tables.LinkColumn("group_by_id", args=[A("id")])
     short_name = tables.LinkColumn("group_by_id", args=[A("id")])
+    school_term = tables.Column()
 
 
 class AdditionalFieldsTable(tables.Table):
diff --git a/aleksis/core/templates/core/school_term/create.html b/aleksis/core/templates/core/school_term/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..06fea51ec6d3196d881e97a61c2a9f2579c8a3c7
--- /dev/null
+++ b/aleksis/core/templates/core/school_term/create.html
@@ -0,0 +1,17 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/save_button.html" %}
+  </form>
+
+{% endblock %}
diff --git a/aleksis/core/templates/core/school_term/edit.html b/aleksis/core/templates/core/school_term/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..ea0d0d379a9be4f9a341ced494cc89a8d7382b4a
--- /dev/null
+++ b/aleksis/core/templates/core/school_term/edit.html
@@ -0,0 +1,17 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/save_button.html" %}
+  </form>
+
+{% endblock %}
diff --git a/aleksis/core/templates/core/school_term/list.html b/aleksis/core/templates/core/school_term/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..14aa2f0a697a3efd54b27154b431ef3f424923de
--- /dev/null
+++ b/aleksis/core/templates/core/school_term/list.html
@@ -0,0 +1,18 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="btn green waves-effect waves-light" href="{% url 'create_school_term' %}">
+    <i class="material-icons left">add</i>
+    {% trans "Create school term" %}
+  </a>
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 766275b5deb78a937b10c93786e29480c0bd2f4b..32082e2703c6f757c2c9ce38637c0113dfb147c7 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -22,6 +22,9 @@ urlpatterns = [
     path("status/", views.system_status, name="system_status"),
     path("", include(tf_urls)),
     path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
+    path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"),
+    path("school_terms/create/", views.SchoolTermCreateView.as_view(), name="create_school_term"),
+    path("school_terms/<int:pk>/", views.SchoolTermEditView.as_view(), name="edit_school_term"),
     path("persons", views.persons, name="persons"),
     path("persons/accounts", views.persons_accounts, name="persons_accounts"),
     path("person", views.person, name="person"),
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 92f0bf2fc5d5fd863c9427f4acac9a65081c5b5f..4f166c18bca4eafe50bb9af273e013e2e14c1f44 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -2,20 +2,20 @@ from typing import Optional
 
 from django.apps import apps
 from django.conf import settings
-from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import PermissionDenied
 from django.core.paginator import Paginator
 from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse_lazy
 from django.utils.translation import gettext_lazy as _
 
-from django_tables2 import RequestConfig
+from django_tables2 import RequestConfig, SingleTableView
 from dynamic_preferences.forms import preference_form_builder
 from guardian.shortcuts import get_objects_for_user
 from haystack.inputs import AutoQuery
 from haystack.query import SearchQuerySet
 from haystack.views import SearchView
-from rules.contrib.views import permission_required
+from rules.contrib.views import PermissionRequiredMixin, permission_required
 
 from .filters import GroupFilter
 from .forms import (
@@ -28,8 +28,10 @@ from .forms import (
     GroupPreferenceForm,
     PersonPreferenceForm,
     PersonsAccountsFormSet,
+    SchoolTermForm,
     SitePreferenceForm,
 )
+from .mixins import AdvancedCreateView, AdvancedEditView
 from .models import (
     AdditionalField,
     Announcement,
@@ -38,13 +40,20 @@ from .models import (
     GroupType,
     Notification,
     Person,
+    SchoolTerm,
 )
 from .registries import (
     group_preferences_registry,
     person_preferences_registry,
     site_preferences_registry,
 )
-from .tables import AdditionalFieldsTable, GroupsTable, GroupTypesTable, PersonsTable
+from .tables import (
+    AdditionalFieldsTable,
+    GroupsTable,
+    GroupTypesTable,
+    PersonsTable,
+    SchoolTermTable,
+)
 from .util import messages
 from .util.apps import AppConfig
 from .util.core_helpers import objectgetter_optional
@@ -91,6 +100,37 @@ def about(request: HttpRequest) -> HttpResponse:
     return render(request, "core/pages/about.html", context)
 
 
+class SchoolTermListView(SingleTableView, PermissionRequiredMixin):
+    """Table of all school terms."""
+
+    model = SchoolTerm
+    table_class = SchoolTermTable
+    permission_required = "core.view_schoolterm"
+    template_name = "core/school_term/list.html"
+
+
+class SchoolTermCreateView(AdvancedCreateView, PermissionRequiredMixin):
+    """Create view for school terms."""
+
+    model = SchoolTerm
+    form_class = SchoolTermForm
+    permission_required = "core.add_schoolterm"
+    template_name = "core/school_term/create.html"
+    success_url = reverse_lazy("school_terms")
+    success_message = _("The school term has been created.")
+
+
+class SchoolTermEditView(AdvancedEditView, PermissionRequiredMixin):
+    """Edit view for school terms."""
+
+    model = SchoolTerm
+    form_class = SchoolTermForm
+    permission_required = "core.edit_schoolterm"
+    template_name = "core/school_term/edit.html"
+    success_url = reverse_lazy("school_terms")
+    success_message = _("The school term has been saved.")
+
+
 @permission_required("core.view_persons")
 def persons(request: HttpRequest) -> HttpResponse:
     """List view listing all persons."""