diff --git a/aleksis/apps/chronos/admin.py b/aleksis/apps/chronos/admin.py
index 34b8df69b1effd3169e6b1557f0501674cd6734d..8bcc9d9fd975afaf5af42e1f286cd137283ac43b 100644
--- a/aleksis/apps/chronos/admin.py
+++ b/aleksis/apps/chronos/admin.py
@@ -8,6 +8,7 @@ from guardian.admin import GuardedModelAdmin
 from .models import (
     Absence,
     AbsenceReason,
+    AutomaticPlan,
     Break,
     Event,
     ExtraLesson,
@@ -212,3 +213,5 @@ class ValidityRangeAdmin(admin.ModelAdmin):
 
 
 admin.site.register(ValidityRange, ValidityRangeAdmin)
+
+admin.site.register(AutomaticPlan)
diff --git a/aleksis/apps/chronos/forms.py b/aleksis/apps/chronos/forms.py
index 3233cf8915326860c3d5370915c5ae37bd059a08..b628c2d5840edc4acf2fe555427f1d4fa70ce66d 100644
--- a/aleksis/apps/chronos/forms.py
+++ b/aleksis/apps/chronos/forms.py
@@ -2,11 +2,11 @@ from django import forms
 from django.utils.translation import gettext_lazy as _
 
 from django_select2.forms import ModelSelect2MultipleWidget
-from material import Fieldset
+from material import Fieldset, Layout
 
 from aleksis.core.forms import AnnouncementForm
 
-from .models import LessonSubstitution
+from .models import AutomaticPlan, LessonSubstitution
 
 
 class LessonSubstitutionForm(forms.ModelForm):
@@ -27,3 +27,11 @@ class LessonSubstitutionForm(forms.ModelForm):
 
 
 AnnouncementForm.add_node_to_layout(Fieldset(_("Options for timetables"), "show_in_timetables"))
+
+
+class AutomaticPlanForm(forms.ModelForm):
+    layout = Layout("slug", "name", "number_of_days", "show_header_box")
+
+    class Meta:
+        model = AutomaticPlan
+        fields = ["slug", "name", "number_of_days", "show_header_box"]
diff --git a/aleksis/apps/chronos/menus.py b/aleksis/apps/chronos/menus.py
index 89fb6d8ddb6383e71c167191abb9a428fc62f25e..ec6337f7404c12abe07c32f6772420fe320be14c 100644
--- a/aleksis/apps/chronos/menus.py
+++ b/aleksis/apps/chronos/menus.py
@@ -56,6 +56,17 @@ MENUS = {
                         ),
                     ],
                 },
+                {
+                    "name": _("Automatic plans"),
+                    "url": "automatic_plans",
+                    "icon": "update",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "chronos.view_automaticplans",
+                        ),
+                    ],
+                },
             ],
         }
     ]
diff --git a/aleksis/apps/chronos/rules.py b/aleksis/apps/chronos/rules.py
index d34a3f494ee0e3d06ef6574ac2f4ff8d4f01104b..9947a565d0d2c56a9a4ee6fab561c4e6f1ee2ddc 100644
--- a/aleksis/apps/chronos/rules.py
+++ b/aleksis/apps/chronos/rules.py
@@ -51,3 +51,31 @@ add_perm("chronos.view_substitutions_rule", view_substitutions_predicate)
 # View room (timetable)
 view_room_predicate = has_person & has_room_timetable_perm
 add_perm("chronos.view_room_rule", view_room_predicate)
+
+# View automatic plan list
+view_automatic_plans_predicate = has_person & has_global_perm("chronos.view_automaticplan")
+add_perm("chronos.view_automaticplans_rule", view_automatic_plans_predicate)
+
+# View automatic plan
+view_automatic_plan_predicate = has_person & (
+    has_global_perm("chronos.view_automaticplan") | has_object_perm("chronos.view_automaticplan")
+)
+add_perm("chronos.view_automaticplan_rule", view_automatic_plan_predicate)
+
+# Add automatic plan
+add_automatic_plan_predicate = view_automatic_plans_predicate & has_global_perm(
+    "chronos.add_automaticplan"
+)
+add_perm("chronos.add_automaticplan_rule", add_automatic_plan_predicate)
+
+# Edit automatic plan
+edit_automatic_plan_predicate = view_automatic_plans_predicate & has_global_perm(
+    "chronos.change_automaticplan"
+)
+add_perm("chronos.edit_automaticplan_rule", edit_automatic_plan_predicate)
+
+# Delete automatic plan
+delete_automatic_plan_predicate = view_automatic_plans_predicate & has_global_perm(
+    "chronos.delete_automaticplan"
+)
+add_perm("chronos.delete_automaticplan_rule", delete_automatic_plan_predicate)
diff --git a/aleksis/apps/chronos/tables.py b/aleksis/apps/chronos/tables.py
index 6caff934e93157707f155dd23d5adfc9b5178610..bb699dcbc45255bf31ef9a95f73ae8f69f4f9ff7 100644
--- a/aleksis/apps/chronos/tables.py
+++ b/aleksis/apps/chronos/tables.py
@@ -42,3 +42,28 @@ class LessonsTable(tables.Table):
         attrs={"a": {"class": "btn-flat waves-effect waves-orange"}},
         verbose_name=_("Manage substitution"),
     )
+
+
+class AutomaticPlanTable(tables.Table):
+    """Table for automatic plans."""
+
+    class Meta:
+        attrs = {"class": "highlight"}
+
+    name = tables.LinkColumn("edit_automatic_plan", args=[A("id")])
+    filename = tables.LinkColumn("show_automatic_plan", args=[A("slug")])
+    local_path = tables.Column()
+    last_update = tables.DateTimeColumn()
+    last_update_triggered_manually = tables.BooleanColumn()
+    edit = tables.LinkColumn(
+        "edit_automatic_plan",
+        args=[A("id")],
+        text=_("Edit"),
+        attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}},
+    )
+    delete = tables.LinkColumn(
+        "delete_automatic_plan",
+        args=[A("id")],
+        text=_("Delete"),
+        attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}},
+    )
diff --git a/aleksis/apps/chronos/templates/chronos/automatic_plan/create.html b/aleksis/apps/chronos/templates/chronos/automatic_plan/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..7e41d2207882edf877f738911fdc96296b82f1a8
--- /dev/null
+++ b/aleksis/apps/chronos/templates/chronos/automatic_plan/create.html
@@ -0,0 +1,15 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Create automatic plan{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Create automatic plan{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+{% endblock %}
diff --git a/aleksis/apps/chronos/templates/chronos/automatic_plan/edit.html b/aleksis/apps/chronos/templates/chronos/automatic_plan/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..db00248670a7b007d132d7f238edae67243798d6
--- /dev/null
+++ b/aleksis/apps/chronos/templates/chronos/automatic_plan/edit.html
@@ -0,0 +1,15 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n %}
+
+{% block browser_title %}{% blocktrans %}Edit automatic plan{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit automatic plan{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+{% endblock %}
diff --git a/aleksis/apps/chronos/templates/chronos/automatic_plan/list.html b/aleksis/apps/chronos/templates/chronos/automatic_plan/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..a62447c5c51ca50334ec1e56134f50fc5f7a44b9
--- /dev/null
+++ b/aleksis/apps/chronos/templates/chronos/automatic_plan/list.html
@@ -0,0 +1,18 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Automatic plans{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Autoamtic plans{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  <a class="btn green waves-effect waves-light" href="{% url 'create_automatic_plan' %}">
+    <i class="material-icons left">add</i>
+    {% trans "Create automatic plan" %}
+  </a>
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/apps/chronos/urls.py b/aleksis/apps/chronos/urls.py
index fc6d9a868e871b303a6dd0e1b6a8cdfc50be63ed..765db990959292a0fbfba4b1f9137d941a9c208c 100644
--- a/aleksis/apps/chronos/urls.py
+++ b/aleksis/apps/chronos/urls.py
@@ -54,4 +54,25 @@ urlpatterns = [
         {"is_print": True},
         name="substitutions_print_by_date",
     ),
+    path("automatic_plans/", views.AutomaticPlanListView.as_view(), name="automatic_plans"),
+    path(
+        "automatic_plans/create/",
+        views.AutomaticPlanCreateView.as_view(),
+        name="create_automatic_plan",
+    ),
+    path(
+        "automatic_plans/<int:pk>/edit/",
+        views.AutomaticPlanEditView.as_view(),
+        name="edit_automatic_plan",
+    ),
+    path(
+        "automatic_plans/<int:pk>/delete/",
+        views.AutomaticPlanDeleteView.as_view(),
+        name="delete_automatic_plan",
+    ),
+    path(
+        "automatic_plans/<str:slug>.pdf",
+        views.AutomaticPlanShowView.as_view(),
+        name="show_automatic_plan",
+    ),
 ]
diff --git a/aleksis/apps/chronos/views.py b/aleksis/apps/chronos/views.py
index 67e256a352e5cedffb6290673e7b903f49e4117e..2829ddc43f860e012f469f2798f913f305a2fa1a 100644
--- a/aleksis/apps/chronos/views.py
+++ b/aleksis/apps/chronos/views.py
@@ -1,26 +1,31 @@
 from datetime import datetime, timedelta
-from typing import Optional
+from typing import Any, Optional
 
 from django.db.models import Q
-from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
+from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound
 from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
 from django.utils import timezone
+from django.utils.decorators import method_decorator
 from django.utils.translation import ugettext as _
+from django.views import View
 from django.views.decorators.cache import never_cache
+from django.views.generic.detail import SingleObjectMixin
 
-from django_tables2 import RequestConfig
-from rules.contrib.views import permission_required
+from django_tables2 import RequestConfig, SingleTableView
+from reversion.views import RevisionMixin
+from rules.contrib.views import PermissionRequiredMixin, permission_required
 
+from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 from aleksis.core.models import Announcement, Group
 from aleksis.core.util import messages
 from aleksis.core.util.core_helpers import get_site_preferences, has_person
 from aleksis.core.util.pdf import render_pdf
 
-from .forms import LessonSubstitutionForm
+from .forms import AutomaticPlanForm, LessonSubstitutionForm
 from .managers import TimetableType
-from .models import Absence, Holiday, LessonPeriod, LessonSubstitution, TimePeriod
-from .tables import LessonsTable
+from .models import Absence, AutomaticPlan, Holiday, LessonPeriod, LessonSubstitution, TimePeriod
+from .tables import AutomaticPlanTable, LessonsTable
 from .util.build import build_substitutions_list, build_timetable, build_weekdays
 from .util.chronos_helpers import (
     get_classes,
@@ -288,15 +293,16 @@ def delete_substitution(request: HttpRequest, id_: int, week: int) -> HttpRespon
     return redirect("lessons_day_by_date", year=date.year, month=date.month, day=date.day)
 
 
-@permission_required("chronos.view_substitutions_rule")
-def substitutions(
-    request: HttpRequest,
+def get_substitutions_context_data(
+    request: Optional[HttpRequest] = None,
     year: Optional[int] = None,
     month: Optional[int] = None,
     day: Optional[int] = None,
     is_print: bool = False,
-) -> HttpResponse:
-    """View all substitutions on a spcified day."""
+    number_of_days: Optional[int] = None,
+    show_header_box: Optional[bool] = None,
+):
+    """Get context data for the substitutions table."""
     context = {}
 
     if day:
@@ -305,7 +311,14 @@ def substitutions(
     else:
         wanted_day = TimePeriod.get_next_relevant_day(timezone.now().date(), datetime.now().time())
 
-    day_number = get_site_preferences()["chronos__substitutions_print_number_of_days"]
+    day_number = (
+        number_of_days or get_site_preferences()["chronos__substitutions_print_number_of_days"]
+    )
+    show_header_box = (
+        show_header_box
+        if show_header_box is not None
+        else get_site_preferences()["chronos__substitutions_show_header_box"]
+    )
     day_contexts = {}
 
     if is_print:
@@ -324,7 +337,7 @@ def substitutions(
             Announcement.for_timetables().on_date(day).filter(show_in_timetables=True)
         )
 
-        if get_site_preferences()["chronos__substitutions_show_header_box"]:
+        if show_header_box:
             subs = LessonSubstitution.objects.on_day(day).order_by(
                 "lesson_period__lesson__groups", "lesson_period__period"
             )
@@ -353,8 +366,78 @@ def substitutions(
             wanted_day, "substitutions_by_date"
         )
 
-        return render(request, "chronos/substitutions.html", context)
     else:
         context["days"] = day_contexts
 
+    return context
+
+
+@permission_required("chronos.view_substitutions_rule")
+def substitutions(
+    request: HttpRequest,
+    year: Optional[int] = None,
+    month: Optional[int] = None,
+    day: Optional[int] = None,
+    is_print: bool = False,
+) -> HttpResponse:
+    """View all substitutions on a specified day."""
+    context = get_substitutions_context_data(request, year, month, day, is_print)
+    if not is_print:
+        return render(request, "chronos/substitutions.html", context)
+    else:
         return render_pdf(request, "chronos/substitutions_print.html", context)
+
+
+class AutomaticPlanListView(PermissionRequiredMixin, SingleTableView):
+    """Table of all automatic plans."""
+
+    model = AutomaticPlan
+    table_class = AutomaticPlanTable
+    permission_required = "chronos.view_automaticplans_rule"
+    template_name = "chronos/automatic_plan/list.html"
+
+
+@method_decorator(never_cache, name="dispatch")
+class AutomaticPlanCreateView(PermissionRequiredMixin, AdvancedCreateView):
+    """Create view for automatic plans."""
+
+    model = AutomaticPlan
+    form_class = AutomaticPlanForm
+    permission_required = "chronos.add_automaticplan_rule"
+    template_name = "chronos/automatic_plan/create.html"
+    success_url = reverse_lazy("automatic_plans")
+    success_message = _("The automatic plan has been created.")
+
+
+@method_decorator(never_cache, name="dispatch")
+class AutomaticPlanEditView(PermissionRequiredMixin, AdvancedEditView):
+    """Edit view for automatic plans."""
+
+    model = AutomaticPlan
+    form_class = AutomaticPlanForm
+    permission_required = "chronos.edit_automaticplan_rule"
+    template_name = "chronos/automatic_plan/edit.html"
+    success_url = reverse_lazy("automatic_plans")
+    success_message = _("The automatic plan has been saved.")
+
+
+@method_decorator(never_cache, name="dispatch")
+class AutomaticPlanDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
+    """Delete view for automatic plans."""
+
+    model = AutomaticPlan
+    permission_required = "chronos.delete_automaticplan_rule"
+    template_name = "core/pages/delete.html"
+    success_url = reverse_lazy("automatic_plans")
+    success_message = _("The automatic plan has been deleted.")
+
+
+class AutomaticPlanShowView(PermissionRequiredMixin, SingleObjectMixin, View):
+    """Show the current version of the automatic plan."""
+
+    model = AutomaticPlan
+    permission_required = "resint.view_automaticplan_rule"
+
+    def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> FileResponse:
+        automatic_plan = self.get_object()
+        return FileResponse(automatic_plan.get_current_file(), content_type="application/pdf")