diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2f98ddbe996bb240061e0c8dea6226bd23410d24..fb6ad2578ea1f5b4f5c2baeae94285a2f33c69d9 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,3 +11,5 @@ include:
       file: /ci/test/security.yml
     - project: "AlekSIS/official/AlekSIS"
       file: /ci/build/dist.yml
+    - project: "AlekSIS/official/AlekSIS"
+      file: /ci/publish/pypi.yml
diff --git a/aleksis/apps/stoelindeling/forms.py b/aleksis/apps/stoelindeling/forms.py
index e5f8c0edc2e0be6d8d7f6b43ec26f2669c409ebd..a5067b78a2f8bb07010989be18e2e2865ddcdb83 100644
--- a/aleksis/apps/stoelindeling/forms.py
+++ b/aleksis/apps/stoelindeling/forms.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.utils.translation import gettext as _
 
 from django_select2.forms import ModelSelect2Widget
 from material import Layout, Row
@@ -23,11 +24,26 @@ class SeatingPlanCreateForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
 
         qs = Group.objects.all()
-        if not self.request.user.has_perm("stoelindeling.view_seatingplan"):
+        if not self.request.user.has_perm("stoelindeling.add_seatingplan"):
             qs = qs.filter(owners=self.request.user.person)
 
         self.fields["group"].queryset = qs
 
+    def clean(self):
+        cleaned_data = super().clean()
+        if (
+            cleaned_data["group"].subject
+            and cleaned_data["subject"]
+            and cleaned_data["group"].subject != cleaned_data["subject"]
+        ):
+            raise forms.ValidationError(
+                _(
+                    "The group you selected has a fixed subject ({}). "
+                    "You are not allowed to use another subject than it."
+                ).format(cleaned_data["group"].subject)
+            )
+        return cleaned_data
+
     class Meta:
         model = SeatingPlan
         fields = ["group", "subject", "room"]
@@ -47,6 +63,59 @@ class SeatingPlanCreateForm(forms.ModelForm):
         }
 
 
+class SeatingPlanCopyForm(forms.ModelForm):
+    layout = Layout(
+        Row(
+            "room",
+            "group",
+            "subject",
+        )
+    )
+
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop("request")
+        super().__init__(*args, **kwargs)
+
+        self.fields["room"].disabled = True
+
+        qs = Group.objects.filter(parent_groups=self.instance.group)
+        if not self.request.user.has_perm("stoelindeling.add_seatingplan"):
+            qs = qs.filter(owners=self.request.user.person)
+
+        self.fields["group"].queryset = qs
+
+    def clean(self):
+        cleaned_data = super().clean()
+        if cleaned_data["group"] == self.instance.group:
+            raise forms.ValidationError(_("Group cannot be the same as the original."))
+        if (
+            cleaned_data["group"].subject
+            and cleaned_data["subject"]
+            and cleaned_data["group"].subject != cleaned_data["subject"]
+        ):
+            raise forms.ValidationError(
+                _(
+                    "The group you selected has a fixed subject ({}). "
+                    "You are not allowed to use another subject than it."
+                ).format(cleaned_data["group"].subject)
+            )
+        return cleaned_data
+
+    class Meta:
+        model = SeatingPlan
+        fields = ["room", "group", "subject"]
+        widgets = {
+            "group": ModelSelect2Widget(
+                search_fields=["name__icontains", "short_name__icontains"],
+                attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+            ),
+            "subject": ModelSelect2Widget(
+                search_fields=["name__icontains", "short_name__icontains"],
+                attrs={"data-minimum-input-length": 0, "class": "browser-default"},
+            ),
+        }
+
+
 class SeatingPlanForm(forms.ModelForm):
     layout = Layout(
         Row(
diff --git a/aleksis/apps/stoelindeling/menus.py b/aleksis/apps/stoelindeling/menus.py
index 492015d6bfd382eb3c1065dec0feb7124bb180fb..fc2ce01268d08a8463936c232dd08a7afbcd5dbf 100644
--- a/aleksis/apps/stoelindeling/menus.py
+++ b/aleksis/apps/stoelindeling/menus.py
@@ -4,26 +4,14 @@ MENUS = {
     "NAV_MENU_CORE": [
         {
             "name": _("Seating plans"),
-            "url": "#",
-            "root": True,
-            "svg_icon": "mdi:seat-outline",
+            "url": "seating_plans",
+            "svg_icon": "mdi:view-list-outline",
             "validators": [
-                "menu_generator.validators.is_authenticated",
-                "aleksis.core.util.core_helpers.has_person",
+                (
+                    "aleksis.core.util.predicates.permission_validator",
+                    "stoelindeling.view_seatingplans_rule",
+                ),
             ],
-            "submenu": [
-                {
-                    "name": _("All seating plans"),
-                    "url": "seating_plans",
-                    "svg_icon": "mdi:view-list-outline",
-                    "validators": [
-                        (
-                            "aleksis.core.util.predicates.permission_validator",
-                            "stoelindeling.view_seatingplans_rule",
-                        ),
-                    ],
-                },
-            ],
-        }
+        },
     ]
 }
diff --git a/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py b/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py
new file mode 100644
index 0000000000000000000000000000000000000000..4182b5547adb8a49adf14941c696a1249293e622
--- /dev/null
+++ b/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.12 on 2022-04-06 17:54
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('chronos', '0010_remove_subject_unique_name_per_site'),
+        ('stoelindeling', '0003_seat_negative_x_y'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='seatingplan',
+            name='subject',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chronos.subject', verbose_name='Subject'),
+        ),
+    ]
diff --git a/aleksis/apps/stoelindeling/model_extensions.py b/aleksis/apps/stoelindeling/model_extensions.py
new file mode 100644
index 0000000000000000000000000000000000000000..c61ae1583dbe7bedcd5ecf95e9d95aaca6b0e0f0
--- /dev/null
+++ b/aleksis/apps/stoelindeling/model_extensions.py
@@ -0,0 +1,55 @@
+from typing import Union
+
+from django.apps import apps
+
+from aleksis.apps.chronos.models import Room, Subject
+from aleksis.apps.stoelindeling.models import SeatingPlan
+from aleksis.core.models import Group
+
+
+@Group.method
+def get_seating_plan(self, room: Room, subject: Subject = None) -> Union[SeatingPlan, None]:
+    """Find a seating plan for a group."""
+    qs = SeatingPlan.objects.filter(group=self, room=room)
+    if subject:
+        qs = qs.filter(subject=subject)
+    if qs.exists():
+        return qs.first()
+
+    qs = SeatingPlan.objects.filter(group=self, room=room)
+    if qs.exists():
+        return qs.first()
+
+    qs = SeatingPlan.objects.filter(group__child_groups=self, room=room)
+    if qs.exists():
+        return qs.first()
+
+    return None
+
+
+if apps.is_installed("aleksis.apps.alsijil"):
+    from aleksis.apps.alsijil.models import Event, ExtraLesson, LessonPeriod
+
+    def seating_plan_lesson_period(self):
+        """Get the seating plan for a specific lesson period."""
+        if self.get_groups().count() == 1:
+            return self.get_groups().all()[0].get_seating_plan(self.get_room(), self.get_subject())
+        return None
+
+    LessonPeriod.property_(seating_plan_lesson_period, name="seating_plan")
+
+    def seating_plan_extra_lesson(self):
+        """Get the seating plan for an extra lesson."""
+        if self.groups.count() == 1:
+            return self.groups.all()[0].get_seating_plan(self.room, self.subject)
+        return None
+
+    ExtraLesson.property_(seating_plan_extra_lesson, name="seating_plan")
+
+    def seating_plan_event(self):
+        """Get the seating plan for an event."""
+        if self.groups.count() == 1 and self.rooms.count() == 1:
+            return self.groups.all()[0].get_seating_plan(self.rooms.all()[0])
+        return None
+
+    Event.property_(seating_plan_event, name="seating_plan")
diff --git a/aleksis/apps/stoelindeling/rules.py b/aleksis/apps/stoelindeling/rules.py
index c11819bd32f67b69c50e26388a34e6c879460ec0..38dab4ce629d904183f145ea35151fd780e2ebe6 100644
--- a/aleksis/apps/stoelindeling/rules.py
+++ b/aleksis/apps/stoelindeling/rules.py
@@ -5,10 +5,11 @@ from aleksis.core.util.predicates import (
     has_global_perm,
     has_object_perm,
     has_person,
+    is_group_owner,
 )
 
 from .models import SeatingPlan
-from .util.perms import is_group_owner
+from .util.perms import is_plan_child_group_owner, is_plan_group_owner
 
 # View seating plan list
 view_seatingplans_predicate = has_person & (
@@ -22,28 +23,45 @@ add_perm("stoelindeling.view_seatingplans_rule", view_seatingplans_predicate)
 view_seatingplan_predicate = has_person & (
     has_global_perm("stoelindeling.view_seatingplan")
     | has_object_perm("stoelindeling.view_seatingplan")
-    | is_group_owner
+    | is_plan_group_owner
+    | is_plan_child_group_owner
 )
 add_perm("stoelindeling.view_seatingplan_rule", view_seatingplan_predicate)
 
+#
+view_seatingplan_for_group_predicate = has_person & (
+    has_global_perm("stoelindeling.view_seatingplan") | is_group_owner
+)
+add_perm("stoelindeling.view_seatingplan_for_group_rule", view_seatingplan_for_group_predicate)
+
 # Add seating plan
-create_seatingplan_predicate = view_seatingplans_predicate & has_global_perm(
-    "stoelindeling.add_seatingplan"
+create_seatingplan_predicate = has_person & (
+    has_global_perm("stoelindeling.add_seatingplan") | is_group_owner | is_plan_group_owner
 )
 add_perm("stoelindeling.create_seatingplan_rule", create_seatingplan_predicate)
 
+# Copy seating plan
+copy_seatingplan_for_group_predicate = (
+    view_seatingplan_for_group_predicate & create_seatingplan_predicate
+)
+add_perm("stoelindeling.copy_seatingplan_for_group_rule", copy_seatingplan_for_group_predicate)
+
+# Copy seating plan
+copy_seatingplan_predicate = view_seatingplan_predicate
+add_perm("stoelindeling.copy_seatingplan_rule", copy_seatingplan_predicate)
+
 # Edit seating plan
-edit_seatingplan_predicate = view_seatingplans_predicate & (
+edit_seatingplan_predicate = view_seatingplan_predicate & (
     has_global_perm("stoelindeling.change_seatingplan")
-    | is_group_owner
+    | is_plan_group_owner
     | has_object_perm("stoelindeling.change_seatingplan")
 )
 add_perm("stoelindeling.edit_seatingplan_rule", edit_seatingplan_predicate)
 
 # Delete seating plan
-delete_seatingplan_predicate = view_seatingplans_predicate & (
+delete_seatingplan_predicate = view_seatingplan_predicate & (
     has_global_perm("stoelindeling.delete_seatingplan")
-    | is_group_owner
+    | is_plan_group_owner
     | has_object_perm("stoelindeling.delete_seatingplan")
 )
 add_perm("stoelindeling.delete_seatingplan_rule", delete_seatingplan_predicate)
diff --git a/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html b/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html
new file mode 100644
index 0000000000000000000000000000000000000000..0c7ebf369252b2109e915ff68e250a392007a910
--- /dev/null
+++ b/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html
@@ -0,0 +1,26 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+{% load material_form i18n any_js %}
+
+{% block browser_title %}{% blocktrans %}Copy seating plan{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Copy seating plan{% endblocktrans %}{% endblock %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block content %}
+  <p class="flow-text">
+    {% trans "Seating plan to copy" %}: {{ object }}
+  </p>
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/stoelindeling/urls.py b/aleksis/apps/stoelindeling/urls.py
index 94c22cccb1bce8305d177d907726c3fdc33ecc80..0e1ce6b5ef8ec2ee8a78644c4647ec0b3217d556 100644
--- a/aleksis/apps/stoelindeling/urls.py
+++ b/aleksis/apps/stoelindeling/urls.py
@@ -19,6 +19,11 @@ urlpatterns = [
         views.SeatingPlanEditView.as_view(),
         name="edit_seating_plan",
     ),
+    path(
+        "seating_plans/<int:pk>/copy/",
+        views.SeatingPlanCopyView.as_view(),
+        name="copy_seating_plan",
+    ),
     path(
         "seating_plans/<int:pk>/delete/",
         views.SeatingPlanDeleteView.as_view(),
diff --git a/aleksis/apps/stoelindeling/util/perms.py b/aleksis/apps/stoelindeling/util/perms.py
index c55d09bf904f2dc618c9adcefbe982aba1fa516b..2b00e287cf3fa95e4410a163604736b732fcb6bb 100644
--- a/aleksis/apps/stoelindeling/util/perms.py
+++ b/aleksis/apps/stoelindeling/util/perms.py
@@ -3,15 +3,35 @@ from django.db.models import Q
 from guardian.shortcuts import get_objects_for_user
 from rules import predicate
 
+from aleksis.core.models import Group, Person
+
 from ..models import SeatingPlan
 
 
 @predicate
-def is_group_owner(user, seating_plan: SeatingPlan) -> bool:
+def is_group_owner(user, group: Group) -> bool:
+    """Predicate which checks if the user is a owner of the group."""
+    if not isinstance(group, Group):
+        return False
+    return user.person in group.owners.all()
+
+
+@predicate
+def is_plan_group_owner(user, seating_plan: SeatingPlan) -> bool:
     """Predicate which checks if the user is a owner of the seating plan's group."""
+    if not isinstance(seating_plan, SeatingPlan):
+        return False
     return user.person in seating_plan.group.owners.all()
 
 
+@predicate
+def is_plan_child_group_owner(user, seating_plan: SeatingPlan) -> bool:
+    """Predicate which checks if the user is an owner of the seating plan's child groups."""
+    if not isinstance(seating_plan, SeatingPlan):
+        return False
+    return user.person in Person.objects.filter(owner_of__in=seating_plan.group.child_groups.all())
+
+
 def get_allowed_seating_plans(user):
     """Get all seating plans the user is allowed to see."""
     if not user.has_perm("stoelindeling.view_seatingplan"):
diff --git a/aleksis/apps/stoelindeling/views.py b/aleksis/apps/stoelindeling/views.py
index 51b718935d482f2861e1dc57e52f4d5549c761a7..0b3f4b9c4c201667f73cf9591448f05680cbb697 100644
--- a/aleksis/apps/stoelindeling/views.py
+++ b/aleksis/apps/stoelindeling/views.py
@@ -1,6 +1,6 @@
 from django.contrib import messages
 from django.shortcuts import redirect
-from django.urls import reverse_lazy
+from django.urls import reverse, reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.translation import gettext as _
 from django.views.decorators.cache import never_cache
@@ -10,9 +10,15 @@ from django_tables2 import SingleTableView
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin
 
-from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
+from aleksis.core.mixins import (
+    AdvancedCreateView,
+    AdvancedDeleteView,
+    AdvancedEditView,
+    SuccessNextMixin,
+)
+from aleksis.core.views import LoginView
 
-from .forms import SeatFormSet, SeatingPlanCreateForm, SeatingPlanForm
+from .forms import SeatFormSet, SeatingPlanCopyForm, SeatingPlanCreateForm, SeatingPlanForm
 from .models import Seat, SeatingPlan
 from .tables import SeatingPlanTable
 from .util.perms import get_allowed_seating_plans
@@ -39,7 +45,7 @@ class SeatingPlanDetailView(PermissionRequiredMixin, DetailView):
 
 
 @method_decorator(never_cache, name="dispatch")
-class SeatingPlanCreateView(PermissionRequiredMixin, AdvancedCreateView):
+class SeatingPlanCreateView(PermissionRequiredMixin, SuccessNextMixin, AdvancedCreateView):
     """Create view for seating plans."""
 
     model = SeatingPlan
@@ -52,11 +58,19 @@ class SeatingPlanCreateView(PermissionRequiredMixin, AdvancedCreateView):
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
         kwargs["request"] = self.request
+        initial = {}
+        if "room" in self.request.GET:
+            initial["room"] = self.request.GET["room"]
+        if "subject" in self.request.GET:
+            initial["subject"] = self.request.GET["subject"]
+        if "group" in self.request.GET:
+            initial["group"] = self.request.GET["group"]
+        kwargs["initial"] = initial
         return kwargs
 
 
 @method_decorator(never_cache, name="dispatch")
-class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView):
+class SeatingPlanEditView(PermissionRequiredMixin, SuccessNextMixin, AdvancedEditView):
     """Edit view for seating plans."""
 
     model = SeatingPlan
@@ -65,6 +79,9 @@ class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView):
     template_name = "stoelindeling/seating_plan/edit.html"
     success_message = _("The seating plan has been saved.")
 
+    def get_success_url(self):
+        return LoginView.get_redirect_url(self) or reverse("seating_plan", args=[self.object.pk])
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
@@ -112,13 +129,52 @@ class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView):
             Seat.objects.bulk_update(objects_to_update, ["x", "y", "seated"])
 
             messages.success(self.request, _("The seating plan has been updated."))
-            return redirect("seating_plan", self.object.pk)
+            return redirect(self.get_success_url())
 
         return super().form_invalid(form)
 
 
 @method_decorator(never_cache, name="dispatch")
-class SeatingPlanDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
+class SeatingPlanCopyView(PermissionRequiredMixin, SuccessNextMixin, AdvancedEditView):
+    """Copy view for seating plans."""
+
+    model = SeatingPlan
+    form_class = SeatingPlanCopyForm
+    permission_required = "stoelindeling.copy_seatingplan_rule"
+    template_name = "stoelindeling/seating_plan/copy.html"
+
+    def get_success_url(self):
+        return reverse("edit_seating_plan", args=[self.new_object.pk])  # FiXME NEXT URL
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs["request"] = self.request
+        return kwargs
+
+    def form_valid(self, form):
+        context = self.get_context_data()
+
+        self.new_object = self.object
+        self.new_object.pk = None
+        self.new_object.group = form.cleaned_data["group"]
+        self.new_object.subject = form.cleaned_data["subject"]
+        self.new_object.save()
+
+        for seat in self.object.seats.all():
+            if seat.person in self.new_object.group.members.all():
+                new_seat = seat
+                new_seat.pk = None
+                new_seat.seating_plan = self.new_object
+                new_seat.save()
+
+        messages.success(self.request, _("The seating plan has been copied successfully."))
+        return redirect(self.get_success_url())
+
+
+@method_decorator(never_cache, name="dispatch")
+class SeatingPlanDeleteView(
+    PermissionRequiredMixin, RevisionMixin, SuccessNextMixin, AdvancedDeleteView
+):
     """Delete view for seating plans."""
 
     model = SeatingPlan