From 58be5a14673d8e5a359e2f5e69c77dfd3f210fd1 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Thu, 23 May 2024 21:43:08 +0200
Subject: [PATCH 1/2] Use preferences to set group types for school structure

Instead of managed, hard-coded group types
---
 aleksis/apps/cursus/apps.py             |  9 ------
 aleksis/apps/cursus/preferences.py      | 37 +++++++++++++++++++++++++
 aleksis/apps/cursus/rules.py            |  8 +++++-
 aleksis/apps/cursus/schema.py           | 32 ++++++++++++---------
 aleksis/apps/cursus/settings.py         |  2 --
 aleksis/apps/cursus/util/group_types.py | 17 ------------
 6 files changed, 63 insertions(+), 42 deletions(-)
 create mode 100644 aleksis/apps/cursus/preferences.py
 delete mode 100644 aleksis/apps/cursus/settings.py
 delete mode 100644 aleksis/apps/cursus/util/group_types.py

diff --git a/aleksis/apps/cursus/apps.py b/aleksis/apps/cursus/apps.py
index ba7bf20..2100153 100644
--- a/aleksis/apps/cursus/apps.py
+++ b/aleksis/apps/cursus/apps.py
@@ -11,12 +11,3 @@ class DefaultConfig(AppConfig):
     }
     licence = "EUPL-1.2+"
     copyright_info = (([2023], "Jonathan Weth", "dev@jonathanweth.de"),)
-
-    def _maintain_default_data(self):
-        super()._maintain_default_data()
-
-        # Ensure that default group types for school structure exist
-        from .util.group_types import get_school_class_group_type, get_school_grade_group_type
-
-        get_school_grade_group_type()
-        get_school_class_group_type()
diff --git a/aleksis/apps/cursus/preferences.py b/aleksis/apps/cursus/preferences.py
new file mode 100644
index 0000000..49044e6
--- /dev/null
+++ b/aleksis/apps/cursus/preferences.py
@@ -0,0 +1,37 @@
+from django.utils.translation import gettext_lazy as _
+
+from dynamic_preferences.preferences import Section
+from dynamic_preferences.types import ModelChoicePreference
+
+from aleksis.core.models import GroupType
+from aleksis.core.registries import site_preferences_registry
+
+cursus = Section("cursus", verbose_name=_("Course management"))
+
+
+@site_preferences_registry.register
+class SchoolGradeGroupType(ModelChoicePreference):
+    section = cursus
+    name = "school_grade_group_type"
+    required = False
+    default = None
+    model = GroupType
+    verbose_name = _("Group type for school grades")
+    help_text = _(
+        "You have to set this and the group type for "
+        "school classes to use the school structure tool."
+    )
+
+
+@site_preferences_registry.register
+class SchoolClassGroupType(ModelChoicePreference):
+    section = cursus
+    name = "school_class_group_type"
+    required = False
+    default = None
+    model = GroupType
+    verbose_name = _("Group type for school classes")
+    help_text = _(
+        "You have to set this and the group type for "
+        "school grades to use the school structure tool."
+    )
diff --git a/aleksis/apps/cursus/rules.py b/aleksis/apps/cursus/rules.py
index 5944379..1bf3e6c 100644
--- a/aleksis/apps/cursus/rules.py
+++ b/aleksis/apps/cursus/rules.py
@@ -5,6 +5,7 @@ from aleksis.core.util.predicates import (
     has_global_perm,
     has_object_perm,
     has_person,
+    is_site_preference_set,
 )
 
 from .models import Course, Subject
@@ -59,7 +60,12 @@ delete_course_predicate = view_course_predicate & (
 )
 add_perm("cursus.delete_course_rule", delete_course_predicate)
 
-manage_school_structure_predicate = has_person & has_global_perm("cursus.manage_school_structure")
+manage_school_structure_predicate = (
+    has_person
+    & is_site_preference_set("cursus", "school_grade_group_type")
+    & is_site_preference_set("cursus", "school_class_group_type")
+    & has_global_perm("cursus.manage_school_structure")
+)
 add_perm("cursus.manage_school_structure_rule", manage_school_structure_predicate)
 
 view_cursus_menu_predicate = (
diff --git a/aleksis/apps/cursus/schema.py b/aleksis/apps/cursus/schema.py
index 6d40775..32bb557 100644
--- a/aleksis/apps/cursus/schema.py
+++ b/aleksis/apps/cursus/schema.py
@@ -10,11 +10,6 @@ from graphene_django_cud.mutations import (
 )
 from guardian.shortcuts import get_objects_for_user
 
-from aleksis.apps.cursus.settings import SCHOOL_CLASS_GROUP_TYPE_NAME, SCHOOL_GRADE_GROUP_TYPE_NAME
-from aleksis.apps.cursus.util.group_types import (
-    get_school_class_group_type,
-    get_school_grade_group_type,
-)
 from aleksis.core.models import Group, Person
 from aleksis.core.schema.base import (
     DjangoFilterMixin,
@@ -25,7 +20,7 @@ from aleksis.core.schema.base import (
 )
 from aleksis.core.schema.group import GroupType as GraphQLGroupType
 from aleksis.core.schema.person import PersonType as GraphQLPersonType
-from aleksis.core.util.core_helpers import has_person
+from aleksis.core.util.core_helpers import get_site_preferences, has_person
 
 from .models import Course, Subject
 
@@ -206,7 +201,9 @@ class CreateSchoolClassMutation(DjangoBatchCreateMutation):
 
     @classmethod
     def before_mutate(cls, root, info, input):  # noqa
-        group_type = get_school_class_group_type()
+        group_type = get_site_preferences()["cursus__school_class_group_type"]
+        if not group_type:
+            raise PermissionDenied()
         for school_class in input:
             school_class["group_type"] = group_type.pk
         return input
@@ -220,7 +217,9 @@ class CreateSchoolGradeMutation(DjangoBatchCreateMutation):
 
     @classmethod
     def before_mutate(cls, root, info, input):  # noqa
-        group_type = get_school_grade_group_type()
+        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+        if not group_type:
+            raise PermissionDenied()
         for school_grade in input:
             school_grade["group_type"] = group_type.pk
         return input
@@ -247,28 +246,35 @@ class Query(graphene.ObjectType):
 
     @staticmethod
     def resolve_school_classes(root, info, **kwargs):
+        group_type = get_site_preferences()["cursus__school_class_group_type"]
+        if not group_type:
+            return []
         return get_objects_for_user(
             info.context.user,
             "core.view_group",
-            Group.objects.filter(group_type__name=SCHOOL_CLASS_GROUP_TYPE_NAME),
+            Group.objects.filter(group_type=group_type),
         )
 
     @staticmethod
     def resolve_school_grades(root, info, **kwargs):
+        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+        if not group_type:
+            return []
         return get_objects_for_user(
             info.context.user,
             "core.view_group",
-            Group.objects.filter(group_type__name=SCHOOL_GRADE_GROUP_TYPE_NAME),
+            Group.objects.filter(group_type=group_type),
         )
 
     @staticmethod
     def resolve_school_grades_by_term(root, info, school_term):
+        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+        if not group_type:
+            return []
         return get_objects_for_user(
             info.context.user,
             "core.view_group",
-            Group.objects.filter(school_term__id=school_term).filter(
-                group_type__name=SCHOOL_GRADE_GROUP_TYPE_NAME
-            ),
+            Group.objects.filter(school_term__id=school_term).filter(group_type=group_type),
         )
 
     @staticmethod
diff --git a/aleksis/apps/cursus/settings.py b/aleksis/apps/cursus/settings.py
deleted file mode 100644
index 1065c8c..0000000
--- a/aleksis/apps/cursus/settings.py
+++ /dev/null
@@ -1,2 +0,0 @@
-SCHOOL_GRADE_GROUP_TYPE_NAME = "School grade"
-SCHOOL_CLASS_GROUP_TYPE_NAME = "School class"
diff --git a/aleksis/apps/cursus/util/group_types.py b/aleksis/apps/cursus/util/group_types.py
deleted file mode 100644
index 71f1ebb..0000000
--- a/aleksis/apps/cursus/util/group_types.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from aleksis.core.models import GroupType
-
-from ..settings import SCHOOL_CLASS_GROUP_TYPE_NAME, SCHOOL_GRADE_GROUP_TYPE_NAME
-
-
-def get_school_grade_group_type():
-    group_type, __ = GroupType.objects.managed_by_app("cursus").get_or_create(
-        name=SCHOOL_GRADE_GROUP_TYPE_NAME, managed_by_app_label="cursus"
-    )
-    return group_type
-
-
-def get_school_class_group_type():
-    group_type, __ = GroupType.objects.managed_by_app("cursus").get_or_create(
-        name=SCHOOL_CLASS_GROUP_TYPE_NAME, managed_by_app_label="cursus"
-    )
-    return group_type
-- 
GitLab


From f00f99ce89bf3effa0437b67c94ada98ca09bf87 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Mon, 10 Jun 2024 11:14:35 +0200
Subject: [PATCH 2/2] Use first and second level group types for school
 structure

(instead of hard-coded school grades and classes)
---
 .../frontend/components/SchoolStructure.vue   | 156 +++++++++++-------
 .../components/schoolStructure.graphql        |  35 ++--
 aleksis/apps/cursus/frontend/messages/de.json |  11 +-
 aleksis/apps/cursus/frontend/messages/en.json |  11 +-
 aleksis/apps/cursus/preferences.py            |  18 +-
 aleksis/apps/cursus/rules.py                  |   4 +-
 aleksis/apps/cursus/schema.py                 |  89 ++++++----
 graphql.config.yml                            |   1 +
 8 files changed, 188 insertions(+), 137 deletions(-)
 create mode 100644 graphql.config.yml

diff --git a/aleksis/apps/cursus/frontend/components/SchoolStructure.vue b/aleksis/apps/cursus/frontend/components/SchoolStructure.vue
index 4ebb93e..37dfe21 100644
--- a/aleksis/apps/cursus/frontend/components/SchoolStructure.vue
+++ b/aleksis/apps/cursus/frontend/components/SchoolStructure.vue
@@ -7,31 +7,48 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
 
 <template>
   <v-card>
-    <!-- Create grade form -->
+    <!-- Create first level group form -->
     <dialog-object-form
-      v-model="createGradeForm"
-      :fields="createGradeFields"
-      :default-item="createGradeDefaultItem"
+      v-model="createFirstLevelGroupForm"
+      :fields="firstLevelGroupFields"
+      :default-item="firstLevelGroupDefaultItem"
       :is-create="true"
-      create-item-i18n-key="cursus.school_structure.add_grade"
-      :gql-create-mutation="gqlCreateGrades"
-      :get-create-data="transformCreateGradeItem"
-      @cancel="createGradeForm = false"
+      :gql-create-mutation="gqlCreateFirstLevelGroup"
+      :get-create-data="transformFirstLevelGroupItem"
+      @cancel="createFirstLevelGroupForm = false"
       @save="updateSchoolStructure"
-    />
-    <!-- Create class form -->
+    >
+      <template #title>
+        <span class="text-h5">
+          {{
+            $t("cursus.school_structure.add_title", {
+              name: schoolStructure.firstLevelType.name,
+            })
+          }}
+        </span>
+      </template>
+    </dialog-object-form>
+    <!-- Create second level group form -->
     <dialog-object-form
-      v-model="createClassForm"
-      :fields="createClassFields"
-      :default-item="createClassDefaultItem"
+      v-model="createSecondLevelGroupForm"
+      :fields="secondLevelGroupFields"
+      :default-item="secondLevelGroupDefaultItem"
       :is-create="true"
-      create-item-i18n-key="cursus.school_structure.add_class"
-      :gql-create-mutation="gqlCreateClasses"
-      :get-create-data="transformCreateClassItemForGrade"
-      @cancel="createClassForm = false"
+      :gql-create-mutation="gqlCreateSecondLevelGroup"
+      :get-create-data="transformSecondLevelGroupItem"
+      @cancel="createFirstLevelGroupForm = false"
       @save="updateSchoolStructure"
     >
-      <!-- Hide parentGroups field - it is set on grade -->
+      <template #title>
+        <span class="text-h5">
+          {{
+            $t("cursus.school_structure.add_title", {
+              name: schoolStructure.secondLevelType.name,
+            })
+          }}
+        </span>
+      </template>
+      <!-- Hide parentGroups field - it is set on first level group -->
       <!-- eslint-disable-next-line vue/valid-v-slot -->
       <template #parentGroups.field="{ on, attrs }">
         <input type="hidden" v-bind="attrs" v-on="on" />
@@ -52,19 +69,27 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
         <v-card-actions>
           <create-button
             v-if="this.$data.currentTerm"
-            i18n-key="cursus.school_structure.add_grade"
-            @click="createGrade"
-          />
+            @click="createFirstLevelGroup"
+          >
+            <v-icon left>$plus</v-icon>
+            {{
+              $t("cursus.school_structure.add", {
+                name: schoolStructure.firstLevelType.name,
+              })
+            }}
+          </create-button>
         </v-card-actions>
       </div>
     </div>
-    <!-- Grades -->
+    <!-- First level groups -->
     <v-container v-if="this.$data.currentTerm">
       <v-row class="overflow-x-auto flex-nowrap slide-n-snap-x-container">
         <!-- responsive 1, 2, 3, 4 col layout -->
         <v-col
-          v-for="grade in grades"
-          :key="grade.id"
+          v-for="firstGroup in schoolStructure
+            ? schoolStructure.firstLevelGroupsByTerm
+            : []"
+          :key="firstGroup.id"
           class="slide-n-snap-contained"
           cols="12"
           sm="6"
@@ -74,27 +99,26 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
         >
           <v-card>
             <v-card-title class="justify-end">
-              {{ $t("cursus.school_structure.grade") }}
-              <span class="ml-3 text-h4">{{ grade.shortName }}</span>
+              {{ schoolStructure.firstLevelType.name }}
+              <span class="ml-3 text-h4">{{ firstGroup.shortName }}</span>
             </v-card-title>
             <v-list
               :max-height="$vuetify.breakpoint.height - 333"
               class="overflow-y-auto slide-n-snap-y-container"
             >
-              <!-- class is a "forbidden" name in v-for -->
               <v-list-item
-                v-for="clas in grade.childGroups"
-                :key="clas.id"
+                v-for="secondGroup in firstGroup.childGroups"
+                :key="secondGroup.id"
                 class="slide-n-snap-contained"
               >
                 <v-card class="mx-3 my-2">
                   <div class="d-flex flex-nowrap justify-space-between">
                     <div>
                       <v-card-title class="text-h4">
-                        {{ clas.shortName }}
+                        {{ secondGroup.shortName }}
                       </v-card-title>
                       <v-card-subtitle>
-                        {{ clas.name }}
+                        {{ secondGroup.name }}
                       </v-card-subtitle>
                     </div>
                     <div>
@@ -104,15 +128,15 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
                         class="px-2"
                       >
                         <v-chip
-                          v-for="teacher in clas.owners"
+                          v-for="teacher in secondGroup.owners"
                           :key="teacher.id"
                           :to="{
                             name: 'core.personById',
                             params: { id: teacher.id },
                           }"
-                          :outlined="true"
+                          outlined
                         >
-                          {{ teacher.shortName }}
+                          {{ teacher.shortName || teacher.lastName }}
                         </v-chip>
                       </v-chip-group>
                       <v-card-actions>
@@ -120,7 +144,7 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
                           i18n-key="cursus.school_structure.timetable"
                           :to="{
                             name: 'lesrooster.timetable_management',
-                            params: { id: clas.id },
+                            params: { id: secondGroup.id },
                           }"
                         />
                       </v-card-actions>
@@ -133,10 +157,16 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
               <v-spacer />
               <!-- MAYBE: ADD PLAN COURSES LINK -->
               <create-button
-                i18n-key="cursus.school_structure.add_class"
                 color="secondary"
-                @click="createClass(grade.id)"
-              />
+                @click="createSecondLevelGroup(firstGroup.id)"
+              >
+                <v-icon left>$plus</v-icon>
+                {{
+                  $t("cursus.school_structure.add", {
+                    name: schoolStructure.secondLevelType.name,
+                  })
+                }}
+              </create-button>
             </v-card-actions>
           </v-card>
         </v-col>
@@ -147,38 +177,38 @@ import SchoolTermField from "aleksis.core/components/school_term/SchoolTermField
 
 <script>
 import {
-  gqlSchoolGrades,
-  gqlCreateGrades,
-  gqlCreateClasses,
+  gqlFirstLevelGroups,
+  gqlCreateFirstLevelGroup,
+  gqlCreateSecondLevelGroup,
 } from "./schoolStructure.graphql";
 
 export default {
   name: "SchoolStructure",
   data() {
     return {
-      createGradeForm: false,
-      createGradeFields: [
+      createFirstLevelGroupForm: false,
+      firstLevelGroupFields: [
         {
-          text: this.$t("cursus.school_structure.grade_fields.name"),
+          text: this.$t("cursus.school_structure.fields.name"),
           value: "name",
         },
         {
-          text: this.$t("cursus.school_structure.grade_fields.short_name"),
+          text: this.$t("cursus.school_structure.fields.short_name"),
           value: "shortName",
         },
       ],
-      createGradeDefaultItem: {
+      firstLevelGroupDefaultItem: {
         name: "",
         shortName: "",
       },
-      createClassForm: false,
-      createClassFields: [
+      createSecondLevelGroupForm: false,
+      secondLevelGroupFields: [
         {
-          text: this.$t("cursus.school_structure.class_fields.name"),
+          text: this.$t("cursus.school_structure.fields.name"),
           value: "name",
         },
         {
-          text: this.$t("cursus.school_structure.class_fields.short_name"),
+          text: this.$t("cursus.school_structure.fields.short_name"),
           value: "shortName",
         },
         {
@@ -186,18 +216,18 @@ export default {
           value: "parentGroups",
         },
       ],
-      createClassDefaultItem: {
+      secondLevelGroupDefaultItem: {
         name: "",
         shortName: "",
         parentGroups: [],
       },
-      createClassCurrentGradeID: 0,
+      createSecondLevelGroupFirstLevelGroupId: 0,
       currentTerm: null,
     };
   },
   apollo: {
-    grades: {
-      query: gqlSchoolGrades,
+    schoolStructure: {
+      query: gqlFirstLevelGroups,
       variables() {
         return {
           schoolTerm: this.$data.currentTerm.id,
@@ -209,28 +239,28 @@ export default {
     },
   },
   methods: {
-    createGrade() {
-      this.$data.createGradeForm = true;
+    createFirstLevelGroup() {
+      this.$data.createFirstLevelGroupForm = true;
     },
-    createClass(id) {
-      this.$data.createClassCurrentGradeID = id;
-      this.$data.createClassForm = true;
+    createSecondLevelGroup(id) {
+      this.$data.createSecondLevelGroupFirstLevelGroupId = id;
+      this.$data.createSecondLevelGroupForm = true;
     },
-    transformCreateGradeItem(item) {
+    transformFirstLevelGroupItem(item) {
       return {
         ...item,
         schoolTerm: this.$data.currentTerm.id,
       };
     },
-    transformCreateClassItemForGrade(item) {
+    transformSecondLevelGroupItem(item) {
       return {
         ...item,
         schoolTerm: this.$data.currentTerm.id,
-        parentGroups: this.$data.createClassCurrentGradeID,
+        parentGroups: this.$data.createSecondLevelGroupFirstLevelGroupId,
       };
     },
     updateSchoolStructure() {
-      this.$apollo.queries.grades.refetch();
+      this.$apollo.queries.schoolStructure.refetch();
     },
   },
 };
diff --git a/aleksis/apps/cursus/frontend/components/schoolStructure.graphql b/aleksis/apps/cursus/frontend/components/schoolStructure.graphql
index 269bee1..4c88e65 100644
--- a/aleksis/apps/cursus/frontend/components/schoolStructure.graphql
+++ b/aleksis/apps/cursus/frontend/components/schoolStructure.graphql
@@ -1,23 +1,32 @@
-query gqlSchoolGrades($schoolTerm: ID!) {
-  grades: schoolGradesByTerm(schoolTerm: $schoolTerm) {
-    id
-    name
-    shortName
-    childGroups {
+query gqlFirstLevelGroups($schoolTerm: ID!) {
+  schoolStructure {
+    firstLevelType {
+      name
+    }
+    secondLevelType {
+      name
+    }
+    firstLevelGroupsByTerm(schoolTerm: $schoolTerm) {
       id
       name
       shortName
-      owners {
+      childGroups {
         id
+        name
         shortName
+        owners {
+          id
+          shortName
+          lastName
+        }
       }
     }
   }
 }
 
-mutation gqlCreateGrades($input: [BatchCreateGroupInput]!) {
-  createGrades(input: $input) {
-    grades: groups {
+mutation gqlCreateFirstLevelGroup($input: [BatchCreateGroupInput]!) {
+  createFirstLevelGroups(input: $input) {
+    firstLevelGroups: groups {
       id
       name
       shortName
@@ -28,9 +37,9 @@ mutation gqlCreateGrades($input: [BatchCreateGroupInput]!) {
   }
 }
 
-mutation gqlCreateClasses($input: [BatchCreateGroupInput]!) {
-  createClasses(input: $input) {
-    classes: groups {
+mutation gqlCreateSecondLevelGroup($input: [BatchCreateGroupInput]!) {
+  createSecondLevelGroups(input: $input) {
+    secondLevelGroups: groups {
       id
       name
       shortName
diff --git a/aleksis/apps/cursus/frontend/messages/de.json b/aleksis/apps/cursus/frontend/messages/de.json
index 6e955ba..eb98427 100644
--- a/aleksis/apps/cursus/frontend/messages/de.json
+++ b/aleksis/apps/cursus/frontend/messages/de.json
@@ -30,17 +30,12 @@
     "school_structure": {
       "menu_title": "Schulstruktur",
       "title": "Meine Schulstruktur aus",
-      "grade": "Jahrgang",
-      "add_grade": "Jahrgang hinzufügen",
-      "grade_fields": {
-        "short_name": "Kurzname",
-        "name": "Name"
-      },
-      "add_class": "Klasse hinzufügen",
-      "class_fields": {
+      "fields": {
         "short_name": "Kurzname",
         "name": "Name"
       },
+      "add": "{name} hinzufügen",
+      "add_title": "{name} hinzufügen",
       "timetable": "Stundenplan"
     },
     "errors": {
diff --git a/aleksis/apps/cursus/frontend/messages/en.json b/aleksis/apps/cursus/frontend/messages/en.json
index 9e257d3..45081f4 100644
--- a/aleksis/apps/cursus/frontend/messages/en.json
+++ b/aleksis/apps/cursus/frontend/messages/en.json
@@ -30,17 +30,12 @@
     "school_structure": {
       "menu_title": "School Structure",
       "title": "My School Structure in",
-      "grade": "Grade",
-      "add_grade": "Add grade",
-      "grade_fields": {
-        "short_name": "Short Name",
-        "name": "Name"
-      },
-      "add_class": "Add class",
-      "class_fields": {
+      "fields": {
         "short_name": "Short Name",
         "name": "Name"
       },
+      "add": "Add {name}",
+      "add_title": "Add {name}",
       "timetable": "Timetable"
     },
     "errors": {
diff --git a/aleksis/apps/cursus/preferences.py b/aleksis/apps/cursus/preferences.py
index 49044e6..b356b42 100644
--- a/aleksis/apps/cursus/preferences.py
+++ b/aleksis/apps/cursus/preferences.py
@@ -10,28 +10,26 @@ cursus = Section("cursus", verbose_name=_("Course management"))
 
 
 @site_preferences_registry.register
-class SchoolGradeGroupType(ModelChoicePreference):
+class SchoolStructureFirstLevelGroupType(ModelChoicePreference):
     section = cursus
-    name = "school_grade_group_type"
+    name = "school_structure_first_level_group_type"
     required = False
     default = None
     model = GroupType
-    verbose_name = _("Group type for school grades")
+    verbose_name = _("School structure: Group type for first level (e. g. grades)")
     help_text = _(
-        "You have to set this and the group type for "
-        "school classes to use the school structure tool."
+        "You have to set this and the second level group type to use the school structure tool."
     )
 
 
 @site_preferences_registry.register
-class SchoolClassGroupType(ModelChoicePreference):
+class SchoolStructureSecondLevelGroupType(ModelChoicePreference):
     section = cursus
-    name = "school_class_group_type"
+    name = "school_structure_second_level_group_type"
     required = False
     default = None
     model = GroupType
-    verbose_name = _("Group type for school classes")
+    verbose_name = _("School structure: Group type for second level (e. g. classes)")
     help_text = _(
-        "You have to set this and the group type for "
-        "school grades to use the school structure tool."
+        "You have to set this and the first level group type to use the school structure tool."
     )
diff --git a/aleksis/apps/cursus/rules.py b/aleksis/apps/cursus/rules.py
index 1bf3e6c..c7f6540 100644
--- a/aleksis/apps/cursus/rules.py
+++ b/aleksis/apps/cursus/rules.py
@@ -62,8 +62,8 @@ add_perm("cursus.delete_course_rule", delete_course_predicate)
 
 manage_school_structure_predicate = (
     has_person
-    & is_site_preference_set("cursus", "school_grade_group_type")
-    & is_site_preference_set("cursus", "school_class_group_type")
+    & is_site_preference_set("cursus", "school_structure_first_level_group_type")
+    & is_site_preference_set("cursus", "school_structure_second_level_group_type")
     & has_global_perm("cursus.manage_school_structure")
 )
 add_perm("cursus.manage_school_structure_rule", manage_school_structure_predicate)
diff --git a/aleksis/apps/cursus/schema.py b/aleksis/apps/cursus/schema.py
index 32bb557..2fb6090 100644
--- a/aleksis/apps/cursus/schema.py
+++ b/aleksis/apps/cursus/schema.py
@@ -19,6 +19,7 @@ from aleksis.core.schema.base import (
     PermissionsTypeMixin,
 )
 from aleksis.core.schema.group import GroupType as GraphQLGroupType
+from aleksis.core.schema.group_type import GroupTypeType
 from aleksis.core.schema.person import PersonType as GraphQLPersonType
 from aleksis.core.util.core_helpers import get_site_preferences, has_person
 
@@ -193,7 +194,7 @@ class CourseBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutati
         only_fields = ("id", "name", "subject", "teachers", "groups", "lesson_quota")
 
 
-class CreateSchoolClassMutation(DjangoBatchCreateMutation):
+class CreateSchoolStructureSecondLevelGroupsMutation(DjangoBatchCreateMutation):
     class Meta:
         model = Group
         permissions = ("core.add_group",)
@@ -201,15 +202,15 @@ class CreateSchoolClassMutation(DjangoBatchCreateMutation):
 
     @classmethod
     def before_mutate(cls, root, info, input):  # noqa
-        group_type = get_site_preferences()["cursus__school_class_group_type"]
+        group_type = get_site_preferences()["cursus__school_structure_second_level_group_type"]
         if not group_type:
             raise PermissionDenied()
-        for school_class in input:
-            school_class["group_type"] = group_type.pk
+        for group in input:
+            group["group_type"] = group_type.pk
         return input
 
 
-class CreateSchoolGradeMutation(DjangoBatchCreateMutation):
+class CreateSchoolStructureFirstLevelGroupsMutation(DjangoBatchCreateMutation):
     class Meta:
         model = Group
         permissions = ("core.add_group",)
@@ -217,36 +218,32 @@ class CreateSchoolGradeMutation(DjangoBatchCreateMutation):
 
     @classmethod
     def before_mutate(cls, root, info, input):  # noqa
-        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+        group_type = get_site_preferences()["cursus__school_structure_first_level_group_type"]
         if not group_type:
             raise PermissionDenied()
-        for school_grade in input:
-            school_grade["group_type"] = group_type.pk
+        for group in input:
+            group["group_type"] = group_type.pk
         return input
 
 
-class Query(graphene.ObjectType):
-    subjects = FilterOrderList(SubjectType)
-    courses = FilterOrderList(CourseType)
+class SchoolStructureQuery(graphene.ObjectType):
+    first_level_type = graphene.Field(GroupTypeType)
+    second_level_type = graphene.Field(GroupTypeType)
+    first_level_groups = FilterOrderList(GraphQLGroupType)
+    second_level_groups = FilterOrderList(GraphQLGroupType)
+    first_level_groups_by_term = FilterOrderList(GraphQLGroupType, school_term=graphene.ID())
 
-    school_classes = FilterOrderList(GraphQLGroupType)
-    school_grades = FilterOrderList(GraphQLGroupType)
-    school_grades_by_term = FilterOrderList(GraphQLGroupType, school_term=graphene.ID())
-
-    teachers = FilterOrderList(TeacherType)
-
-    course_by_id = graphene.Field(CourseType, id=graphene.ID())
-    courses_of_teacher = FilterOrderList(CourseType, teacher=graphene.ID())
+    @staticmethod
+    def resolve_first_level_type(root, info, **kwargs):
+        return get_site_preferences()["cursus__school_structure_first_level_group_type"]
 
-    def resolve_course_by_id(root, info, id):  # noqa
-        course = Course.objects.get(pk=id)
-        if not info.context.user.has_perm("cursus.view_course_rule", course):
-            raise PermissionDenied()
-        return course
+    @staticmethod
+    def resolve_second_level_type(root, info, **kwargs):
+        return get_site_preferences()["cursus__school_structure_second_level_group_type"]
 
     @staticmethod
-    def resolve_school_classes(root, info, **kwargs):
-        group_type = get_site_preferences()["cursus__school_class_group_type"]
+    def resolve_first_level_groups(root, info, **kwargs):
+        group_type = get_site_preferences()["cursus__school_structure_first_level_group_type"]
         if not group_type:
             return []
         return get_objects_for_user(
@@ -256,8 +253,8 @@ class Query(graphene.ObjectType):
         )
 
     @staticmethod
-    def resolve_school_grades(root, info, **kwargs):
-        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+    def resolve_second_level_groups(root, info, **kwargs):
+        group_type = get_site_preferences()["cursus__school_structure_second_level_group_type"]
         if not group_type:
             return []
         return get_objects_for_user(
@@ -267,16 +264,38 @@ class Query(graphene.ObjectType):
         )
 
     @staticmethod
-    def resolve_school_grades_by_term(root, info, school_term):
-        group_type = get_site_preferences()["cursus__school_grade_group_type"]
+    def resolve_first_level_groups_by_term(root, info, school_term):
+        group_type = get_site_preferences()["cursus__school_structure_first_level_group_type"]
+        print(
+            group_type,
+            Group.objects.filter(school_term=school_term).filter(group_type=group_type),
+        )
         if not group_type:
             return []
         return get_objects_for_user(
             info.context.user,
             "core.view_group",
-            Group.objects.filter(school_term__id=school_term).filter(group_type=group_type),
+            Group.objects.filter(school_term=school_term).filter(group_type=group_type),
         )
 
+
+class Query(graphene.ObjectType):
+    subjects = FilterOrderList(SubjectType)
+    courses = FilterOrderList(CourseType)
+
+    school_structure = graphene.Field(SchoolStructureQuery)
+
+    teachers = FilterOrderList(TeacherType)
+
+    course_by_id = graphene.Field(CourseType, id=graphene.ID())
+    courses_of_teacher = FilterOrderList(CourseType, teacher=graphene.ID())
+
+    def resolve_course_by_id(root, info, id):  # noqa
+        course = Course.objects.get(pk=id)
+        if not info.context.user.has_perm("cursus.view_course_rule", course):
+            raise PermissionDenied()
+        return course
+
     @staticmethod
     def resolve_teachers(root, info):
         return get_objects_for_user(
@@ -295,6 +314,10 @@ class Query(graphene.ObjectType):
         # FIXME: Permission checking. But maybe it's done in get_queryset
         return teacher.courses_as_teacher.all()
 
+    @staticmethod
+    def resolve_school_structure(root, info):
+        return True
+
 
 class Mutation(graphene.ObjectType):
     create_subjects = SubjectBatchCreateMutation.Field()
@@ -305,5 +328,5 @@ class Mutation(graphene.ObjectType):
     delete_courses = CourseBatchDeleteMutation.Field()
     update_courses = CourseBatchPatchMutation.Field()
 
-    create_grades = CreateSchoolGradeMutation.Field()
-    create_classes = CreateSchoolClassMutation.Field()
+    create_first_level_groups = CreateSchoolStructureFirstLevelGroupsMutation.Field()
+    create_second_level_groups = CreateSchoolStructureSecondLevelGroupsMutation.Field()
diff --git a/graphql.config.yml b/graphql.config.yml
new file mode 100644
index 0000000..385050a
--- /dev/null
+++ b/graphql.config.yml
@@ -0,0 +1 @@
+schema: http://localhost:8000/graphql/
-- 
GitLab