From ff61c3ba997ac65fe20521ba1b57d762ba6900cf Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sat, 11 May 2024 23:24:14 +0200
Subject: [PATCH 01/34] Allow for including courses of child groups in TCC
 raster

---
 .../TimeboundCourseConfigRaster.vue           | 37 +++++++++++++++----
 .../timeboundCourseConfig.graphql             |  8 +++-
 .../apps/lesrooster/frontend/messages/en.json |  5 ++-
 aleksis/apps/lesrooster/schema/__init__.py    | 15 +++++---
 4 files changed, 51 insertions(+), 14 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index e6f52d9b..5fffea52 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -55,6 +55,18 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
             />
           </v-col>
 
+          <v-col
+            cols="6"
+            lg="2"
+            class="d-flex justify-space-between flex-wrap align-center"
+          >
+            <v-switch
+              v-model="includeChildGroups"
+              inset
+              :label="$t('lesrooster.timebound_course_config.filters.include_child_groups')"
+            ></v-switch>
+          </v-col>
+
           <v-spacer />
 
           <v-col
@@ -234,6 +246,7 @@ export default {
       currentCourse: null,
       currentSubject: null,
       loading: false,
+      includeChildGroups: true,
     };
   },
   methods: {
@@ -435,16 +448,25 @@ export default {
         let groupCombinations = {};
 
         subject.courses.forEach((course) => {
-          let groupIds = JSON.stringify(
-            course.groups.map((group) => group.id).sort(),
-          );
+          const ownGroupIDs = course.groups.map((group) => group.id);
+          let groupIDs;
+
+          if (this.includeChildGroups && ownGroupIDs.some((groupID) => !this.groupIDList.includes(groupID))) {
+            groupIDs = JSON.stringify(
+              course.groups.map((group) => group.parentGroups.map((parentGroup) => parentGroup.id)).flat().sort()
+            );
+          } else {
+            groupIDs = JSON.stringify(
+              ownGroupIDs.sort()
+            );
+          }
 
-          if (!groupCombinations[groupIds]) {
-            groupCombinations[groupIds] = [];
+          if (!groupCombinations[groupIDs]) {
+            groupCombinations[groupIDs] = [];
           }
 
-          if (!groupCombinations[groupIds].find((c) => c.id === course.id)) {
-            groupCombinations[groupIds].push({
+          if (!groupCombinations[groupIDs].find((c) => c.id === course.id)) {
+            groupCombinations[groupIDs].push({
               ...course,
             });
           }
@@ -505,6 +527,7 @@ export default {
       variables() {
         return {
           groups: this.groupIDList,
+          includeChildGroups: this.includeChildGroups,
         };
       },
     },
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index 885c6d33..b0e4aad2 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -23,6 +23,11 @@ fragment courseFields on LesroosterExtendedCourseType {
     id
     name
     shortName
+    parentGroups {
+      id
+      name
+      shortName
+    }
   }
   lessonQuota
 }
@@ -93,11 +98,12 @@ mutation updateTimeboundCourseConfigs(
   }
 }
 
-query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) {
+query subjects($orderBy: [String], $filters: JSONString, $groups: [ID], $includeChildGroups: Boolean!) {
   subjects: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
     groups: $groups
+    includeChildGroups: $includeChildGroups
   ) {
     ...subjectFields
     courses {
diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json
index 2a5da55f..02c440cd 100644
--- a/aleksis/apps/lesrooster/frontend/messages/en.json
+++ b/aleksis/apps/lesrooster/frontend/messages/en.json
@@ -81,7 +81,10 @@
       "all_teachers": "All teachers",
       "no_course_selected": "No course selected",
       "create_timebound_course_config": "Create timebound course config",
-      "subject": "Subject"
+      "subject": "Subject",
+      "filters": {
+        "include_child_groups": "Include courses from child groups"
+      }
     },
     "lesson_raster": {
       "menu_title": "Lesson Raster"
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index f432dd7f..3a51a42a 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -109,7 +109,7 @@ class Query(graphene.ObjectType):
     current_validity_range = graphene.Field(ValidityRangeType)
 
     lesrooster_extended_subjects = FilterOrderList(
-        LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID)
+        LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID), include_child_groups=graphene.Boolean(required=True)
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -160,13 +160,18 @@ class Query(graphene.ObjectType):
         return Supervision.objects.all()
 
     @staticmethod
-    def resolve_lesrooster_extended_subjects(root, info, groups):
+    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
+        courses = get_objects_for_user(
+            info.context.user, "cursus.view_course", Course.objects.all()
+        )
+        if include_child_groups:
+            courses = courses.filter(Q(groups__in=groups) | Q(groups__parent_groups__in=groups))
+        else:
+            courses = courses.filter(groups__in=groups)
         subjects = Subject.objects.all().prefetch_related(
             Prefetch(
                 "courses",
-                queryset=get_objects_for_user(
-                    info.context.user, "cursus.view_course", Course.objects.all()
-                ).filter(groups__in=groups),
+                queryset=courses,
             )
         )
         if not info.context.user.has_perm("lesrooster.view_subject_rule"):
-- 
GitLab


From df4de0b67b10881be46b06b186ebe6fe2f3c8245 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sun, 12 May 2024 01:43:01 +0200
Subject: [PATCH 02/34] Add loading indicators

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue    | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 5fffea52..704b94c8 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -15,6 +15,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
       hide-default-footer
       :headers="headers"
       :items="tableItems"
+      :loading="$apollo.queries.subjects.loading"
     >
       <template #top>
         <v-row>
@@ -64,6 +65,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
               v-model="includeChildGroups"
               inset
               :label="$t('lesrooster.timebound_course_config.filters.include_child_groups')"
+              :loading="$apollo.queries.subjects.loading"
             ></v-switch>
           </v-col>
 
@@ -91,6 +93,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
                 !createdCourseConfigs.length &&
                 !createdCourses.length
               "
+              :loading="loading"
               @click="save"
             />
           </v-col>
-- 
GitLab


From e1720c322b6a82b6d463e51e7f865017ee47c77f Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sun, 12 May 2024 01:54:46 +0200
Subject: [PATCH 03/34] Set fixed width in TCC raster

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue     | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 704b94c8..dbdc7cad 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -436,7 +436,7 @@ export default {
     headers() {
       let groupHeadersWithWidth = this.groupHeaders.map((header) => ({
         ...header,
-        width: `${Math.max(95 / this.groupHeaders.length, 15)}vw`,
+        width: "20vw",
       }));
       return [
         {
-- 
GitLab


From ee9165da4c20712b9f45c13f546b92c3dc2913de Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Sun, 12 May 2024 20:46:01 +0200
Subject: [PATCH 04/34] Compute group combinations more efficiently and without
 duplicates

---
 .../TimeboundCourseConfigRaster.vue                       | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index dbdc7cad..fcda5ff4 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -403,10 +403,12 @@ export default {
       return this.selectedGroups.map((group) => group.id);
     },
     subjectGroupCombinations() {
-      return [].concat.apply(
-        [],
-        this.items.map((subject) => Object.keys(subject.groupCombinations)),
+      const uniqueCombinations = new Set(
+        this.items.flatMap(subject =>
+          Object.keys(subject.groupCombinations)
+        )
       );
+      return Array.from(uniqueCombinations);
     },
     groupHeaders() {
       return this.selectedGroups
-- 
GitLab


From d454ea9e4deca96a1c4938886838af3d90346491 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Mon, 13 May 2024 13:31:24 +0200
Subject: [PATCH 05/34] Fix subject resolving mechanism

---
 aleksis/apps/lesrooster/schema/__init__.py | 25 +++++++++++-----------
 1 file changed, 12 insertions(+), 13 deletions(-)

diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 3a51a42a..67b3f349 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -161,22 +161,21 @@ class Query(graphene.ObjectType):
 
     @staticmethod
     def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
-        courses = get_objects_for_user(
-            info.context.user, "cursus.view_course", Course.objects.all()
-        )
         if include_child_groups:
-            courses = courses.filter(Q(groups__in=groups) | Q(groups__parent_groups__in=groups))
+            courses = Course.objects.filter(Q(groups__in=groups) | Q(groups__parent_groups__in=groups))
         else:
-            courses = courses.filter(groups__in=groups)
-        subjects = Subject.objects.all().prefetch_related(
-            Prefetch(
-                "courses",
-                queryset=courses,
-            )
+            courses = Course.objects.filter(groups__in=groups)
+        courses = get_objects_for_user(
+            info.context.user, "cursus.view_course", courses
         )
-        if not info.context.user.has_perm("lesrooster.view_subject_rule"):
-            return get_objects_for_user(info.context.user, "cursus.view_subject", subjects)
-        return subjects
+        subjects = get_objects_for_user(info.context.user, "cursus.view_subject", Subject.objects.all())
+
+        return subjects.prefetch_related(
+                    Prefetch(
+                        "courses",
+                        queryset=courses,
+                    )
+                )
 
     @staticmethod
     def resolve_current_validity_range(root, info):
-- 
GitLab


From 00139d2487c217ffd9310cec107e8ee4db94d253 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Mon, 13 May 2024 13:36:02 +0200
Subject: [PATCH 06/34] Make header generation mechanism more efficient

---
 .../TimeboundCourseConfigRaster.vue           | 69 ++++++++++---------
 1 file changed, 35 insertions(+), 34 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index fcda5ff4..9a42bfee 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -250,6 +250,8 @@ export default {
       currentSubject: null,
       loading: false,
       includeChildGroups: true,
+      subjectGroupCombinations: [],
+      selectedGroupHeaders: [],
     };
   },
   methods: {
@@ -402,38 +404,22 @@ export default {
     groupIDList() {
       return this.selectedGroups.map((group) => group.id);
     },
-    subjectGroupCombinations() {
-      const uniqueCombinations = new Set(
-        this.items.flatMap(subject =>
-          Object.keys(subject.groupCombinations)
-        )
-      );
-      return Array.from(uniqueCombinations);
+    groupCombinationHeaders() {
+      return this.subjectGroupCombinations.map((combination) => {
+        let parsedCombination = JSON.parse(combination);
+        return {
+          text: parsedCombination
+                      .map(
+            (groupID) =>
+                          this.selectedGroups.find((group) => group.id === groupID)?.shortName || this.selectedGroups.find((group) => group.id === groupID)?.shortName,
+            )
+                      .join(", "),
+          value: combination,
+        };
+      });
     },
     groupHeaders() {
-      return this.selectedGroups
-        .map((group) => ({
-          text: group.shortName,
-          value: JSON.stringify([group.id]),
-        }))
-        .concat(
-          this.subjectGroupCombinations.map((combination) => {
-            let parsedCombination = JSON.parse(combination);
-            return {
-              text: parsedCombination
-                .map(
-                  (groupID) =>
-                    this.groups.find((group) => group.id === groupID).shortName,
-                )
-                .join(", "),
-              value: combination,
-            };
-          }),
-        )
-        .filter(
-          (obj, index, self) =>
-            index === self.findIndex((o) => o.value === obj.value),
-        );
+      return [...this.selectedGroupHeaders, ...this.groupCombinationHeaders];
     },
     headers() {
       let groupHeadersWithWidth = this.groupHeaders.map((header) => ({
@@ -449,7 +435,9 @@ export default {
       ].concat(groupHeadersWithWidth);
     },
     items() {
-      return this.subjects.map((subject) => {
+      let groupCombinationsSet = new Set();
+
+      const groupedItems = this.subjects.map((subject) => {
         let groupCombinations = {};
 
         subject.courses.forEach((course) => {
@@ -468,6 +456,9 @@ export default {
 
           if (!groupCombinations[groupIDs]) {
             groupCombinations[groupIDs] = [];
+            if (course.groups.length > 1) {
+              groupCombinationsSet.add(groupIDs);
+            }
           }
 
           if (!groupCombinations[groupIDs].find((c) => c.id === course.id)) {
@@ -489,6 +480,10 @@ export default {
 
         return subject;
       });
+
+      this.subjectGroupCombinations = Array.from(groupCombinationsSet);
+
+      return groupedItems;
     },
     tableItems() {
       return this.items.map((subject) => {
@@ -505,6 +500,15 @@ export default {
       });
     },
   },
+  watch: {
+    selectedGroups(newValue) {
+      this.selectedGroupHeaders = newValue
+                    .map((group) => ({
+                      text: group.shortName,
+                      value: JSON.stringify([group.id]),
+                    }));
+    },
+  },
   apollo: {
     currentValidityRange: {
       query: gqlCurrentValidityRange,
@@ -513,9 +517,6 @@ export default {
         this.internalValidityRange = data.currentValidityRange;
       },
     },
-    groups: {
-      query: gqlGroups,
-    },
     groupsForPlanning: {
       query: gqlGroupsForPlanning,
       result({ data }) {
-- 
GitLab


From 4c12d98598653e82eb850b8435a4e922d83ef36e Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Mon, 13 May 2024 13:39:34 +0200
Subject: [PATCH 07/34] Reformat

---
 .../TimeboundCourseConfigRaster.vue           | 42 ++++++++++++-------
 .../timeboundCourseConfig.graphql             |  7 +++-
 aleksis/apps/lesrooster/schema/__init__.py    | 24 ++++++-----
 3 files changed, 47 insertions(+), 26 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 9a42bfee..e6e3f276 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -64,7 +64,11 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
             <v-switch
               v-model="includeChildGroups"
               inset
-              :label="$t('lesrooster.timebound_course_config.filters.include_child_groups')"
+              :label="
+                $t(
+                  'lesrooster.timebound_course_config.filters.include_child_groups',
+                )
+              "
               :loading="$apollo.queries.subjects.loading"
             ></v-switch>
           </v-col>
@@ -409,11 +413,14 @@ export default {
         let parsedCombination = JSON.parse(combination);
         return {
           text: parsedCombination
-                      .map(
-            (groupID) =>
-                          this.selectedGroups.find((group) => group.id === groupID)?.shortName || this.selectedGroups.find((group) => group.id === groupID)?.shortName,
+            .map(
+              (groupID) =>
+                this.selectedGroups.find((group) => group.id === groupID)
+                  ?.shortName ||
+                this.selectedGroups.find((group) => group.id === groupID)
+                  ?.shortName,
             )
-                      .join(", "),
+            .join(", "),
           value: combination,
         };
       });
@@ -444,14 +451,20 @@ export default {
           const ownGroupIDs = course.groups.map((group) => group.id);
           let groupIDs;
 
-          if (this.includeChildGroups && ownGroupIDs.some((groupID) => !this.groupIDList.includes(groupID))) {
+          if (
+            this.includeChildGroups &&
+            ownGroupIDs.some((groupID) => !this.groupIDList.includes(groupID))
+          ) {
             groupIDs = JSON.stringify(
-              course.groups.map((group) => group.parentGroups.map((parentGroup) => parentGroup.id)).flat().sort()
+              course.groups
+                .map((group) =>
+                  group.parentGroups.map((parentGroup) => parentGroup.id),
+                )
+                .flat()
+                .sort(),
             );
           } else {
-            groupIDs = JSON.stringify(
-              ownGroupIDs.sort()
-            );
+            groupIDs = JSON.stringify(ownGroupIDs.sort());
           }
 
           if (!groupCombinations[groupIDs]) {
@@ -502,11 +515,10 @@ export default {
   },
   watch: {
     selectedGroups(newValue) {
-      this.selectedGroupHeaders = newValue
-                    .map((group) => ({
-                      text: group.shortName,
-                      value: JSON.stringify([group.id]),
-                    }));
+      this.selectedGroupHeaders = newValue.map((group) => ({
+        text: group.shortName,
+        value: JSON.stringify([group.id]),
+      }));
     },
   },
   apollo: {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index b0e4aad2..4f9ca031 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -98,7 +98,12 @@ mutation updateTimeboundCourseConfigs(
   }
 }
 
-query subjects($orderBy: [String], $filters: JSONString, $groups: [ID], $includeChildGroups: Boolean!) {
+query subjects(
+  $orderBy: [String]
+  $filters: JSONString
+  $groups: [ID]
+  $includeChildGroups: Boolean!
+) {
   subjects: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 67b3f349..26e612a0 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -109,7 +109,9 @@ class Query(graphene.ObjectType):
     current_validity_range = graphene.Field(ValidityRangeType)
 
     lesrooster_extended_subjects = FilterOrderList(
-        LesroosterExtendedSubjectType, groups=graphene.List(graphene.ID), include_child_groups=graphene.Boolean(required=True)
+        LesroosterExtendedSubjectType,
+        groups=graphene.List(graphene.ID),
+        include_child_groups=graphene.Boolean(required=True),
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -162,20 +164,22 @@ class Query(graphene.ObjectType):
     @staticmethod
     def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
         if include_child_groups:
-            courses = Course.objects.filter(Q(groups__in=groups) | Q(groups__parent_groups__in=groups))
+            courses = Course.objects.filter(
+                Q(groups__in=groups) | Q(groups__parent_groups__in=groups)
+            )
         else:
             courses = Course.objects.filter(groups__in=groups)
-        courses = get_objects_for_user(
-            info.context.user, "cursus.view_course", courses
+        courses = get_objects_for_user(info.context.user, "cursus.view_course", courses)
+        subjects = get_objects_for_user(
+            info.context.user, "cursus.view_subject", Subject.objects.all()
         )
-        subjects = get_objects_for_user(info.context.user, "cursus.view_subject", Subject.objects.all())
 
         return subjects.prefetch_related(
-                    Prefetch(
-                        "courses",
-                        queryset=courses,
-                    )
-                )
+            Prefetch(
+                "courses",
+                queryset=courses,
+            )
+        )
 
     @staticmethod
     def resolve_current_validity_range(root, info):
-- 
GitLab


From f222ea864e2a90a35cb6eb778291a5ec917050b1 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Mon, 13 May 2024 16:23:35 +0200
Subject: [PATCH 08/34] Some optimisations in frontend

---
 .../TimeboundCourseConfigRaster.vue           | 157 +++++++-----------
 1 file changed, 62 insertions(+), 95 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index e6e3f276..b9dcaafc 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -14,7 +14,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
       disable-pagination
       hide-default-footer
       :headers="headers"
-      :items="tableItems"
+      :items="items"
       :loading="$apollo.queries.subjects.loading"
     >
       <template #top>
@@ -248,14 +248,14 @@ export default {
       subjects: [],
       editedCourseConfigs: [],
       createdCourseConfigs: [],
-      newCourses: [],
       createdCourses: [],
       currentCourse: null,
       currentSubject: null,
       loading: false,
       includeChildGroups: true,
-      subjectGroupCombinations: [],
       selectedGroupHeaders: [],
+      items: [],
+      groupCombinationsSet: new Set(),
     };
   },
   methods: {
@@ -383,30 +383,62 @@ export default {
       ];
     },
     addCourse(subject, groups) {
-      let courseSubjectGroup = this.newCourses.find(
-        (courseSubject) => courseSubject.subject === subject,
-      );
-      if (courseSubjectGroup) {
-        if (courseSubjectGroup.groupCombinations) {
-          this.$set(courseSubjectGroup.groupCombinations, groups, [
+      this.$set(this.items.find((i) => i.subject.id === subject), groups, [
             { teachers: [], newCourse: true },
-          ]);
-        } else {
-          courseSubjectGroup.groupCombinations = {
-            [groups]: [{ teachers: [], newCourse: true }],
-          };
-        }
-      } else {
-        this.newCourses.push({
-          subject: subject,
-          groupCombinations: { [groups]: [{ teachers: [], newCourse: true }] },
+            ]);
+    },
+    generateTableItems(subjects) {
+      return subjects.map((subject) => {
+        let { courses, ...reducedSubject } = subject;
+        let groupCombinations = {};
+
+        courses.forEach((course) => {
+          const ownGroupIDs = course.groups.map((group) => group.id);
+          let groupIDs;
+
+          if (
+            this.includeChildGroups &&
+            ownGroupIDs.some((groupID) => !this.groupIDSet.has(groupID))
+          ) {
+            groupIDs = JSON.stringify(
+              course.groups
+                .flatMap((group) =>
+                  group.parentGroups.map((parentGroup) => parentGroup.id),
+                )
+                .sort(),
+            );
+          } else {
+            groupIDs = JSON.stringify(ownGroupIDs.sort());
+          }
+
+          if (!groupCombinations[groupIDs]) {
+            groupCombinations[groupIDs] = [];
+            if (course.groups.length > 1) {
+              this.groupCombinationsSet.add(groupIDs);
+            }
+            groupCombinations[groupIDs].push({
+              ...course,
+            });
+          } else if (!groupCombinations[groupIDs].some((c) => c.id === course.id)) {
+            groupCombinations[groupIDs].push({
+              ...course,
+            });
+          }
         });
-      }
+
+        return {
+          subject: reducedSubject,
+          ...Object.fromEntries(
+            this.groupHeaders.map((header) => [header.value, []]),
+          ),
+          ...groupCombinations,
+        };
+      });
     },
   },
   computed: {
-    groupIDList() {
-      return this.selectedGroups.map((group) => group.id);
+    groupIDSet() {
+      return new Set(this.selectedGroups.map((group) => group.id));
     },
     groupCombinationHeaders() {
       return this.subjectGroupCombinations.map((combination) => {
@@ -441,76 +473,8 @@ export default {
         },
       ].concat(groupHeadersWithWidth);
     },
-    items() {
-      let groupCombinationsSet = new Set();
-
-      const groupedItems = this.subjects.map((subject) => {
-        let groupCombinations = {};
-
-        subject.courses.forEach((course) => {
-          const ownGroupIDs = course.groups.map((group) => group.id);
-          let groupIDs;
-
-          if (
-            this.includeChildGroups &&
-            ownGroupIDs.some((groupID) => !this.groupIDList.includes(groupID))
-          ) {
-            groupIDs = JSON.stringify(
-              course.groups
-                .map((group) =>
-                  group.parentGroups.map((parentGroup) => parentGroup.id),
-                )
-                .flat()
-                .sort(),
-            );
-          } else {
-            groupIDs = JSON.stringify(ownGroupIDs.sort());
-          }
-
-          if (!groupCombinations[groupIDs]) {
-            groupCombinations[groupIDs] = [];
-            if (course.groups.length > 1) {
-              groupCombinationsSet.add(groupIDs);
-            }
-          }
-
-          if (!groupCombinations[groupIDs].find((c) => c.id === course.id)) {
-            groupCombinations[groupIDs].push({
-              ...course,
-            });
-          }
-        });
-
-        subject = {
-          ...subject,
-          groupCombinations: { ...groupCombinations },
-          newCourses: {
-            ...this.newCourses.find(
-              (courseSubject) => courseSubject.subject === subject.id,
-            )?.groupCombinations,
-          },
-        };
-
-        return subject;
-      });
-
-      this.subjectGroupCombinations = Array.from(groupCombinationsSet);
-
-      return groupedItems;
-    },
-    tableItems() {
-      return this.items.map((subject) => {
-        // eslint-disable-next-line no-unused-vars
-        let { courses, groupCombinations, ...reducedSubject } = subject;
-        return {
-          subject: reducedSubject,
-          ...Object.fromEntries(
-            this.groupHeaders.map((header) => [header.value, []]),
-          ),
-          ...subject.groupCombinations,
-          ...subject.newCourses,
-        };
-      });
+    subjectGroupCombinations() {
+      return Array.from(this.groupCombinationsSet);
     },
   },
   watch: {
@@ -533,21 +497,24 @@ export default {
       query: gqlGroupsForPlanning,
       result({ data }) {
         if (!data) return;
-        console.log(data.groups);
         this.selectedGroups = data.groupsForPlanning;
       },
     },
     subjects: {
       query: subjects,
       skip() {
-        return !this.groupIDList.length;
+        return !this.groupIDSet.size || !this.internalValidityRange;
       },
       variables() {
         return {
-          groups: this.groupIDList,
+          groups: Array.from(this.groupIDSet),
           includeChildGroups: this.includeChildGroups,
         };
       },
+      result({ data }) {
+        if (!data) return;
+        this.items = this.generateTableItems(data.subjects);
+      },
     },
     persons: {
       query: gqlTeachers,
-- 
GitLab


From 6cae2debe76c80e8c043c154dec54ac43764efc0 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Mon, 13 May 2024 16:23:57 +0200
Subject: [PATCH 09/34] Reformat

---
 .../TimeboundCourseConfigRaster.vue                  | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index b9dcaafc..3b56b428 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -383,9 +383,11 @@ export default {
       ];
     },
     addCourse(subject, groups) {
-      this.$set(this.items.find((i) => i.subject.id === subject), groups, [
-            { teachers: [], newCourse: true },
-            ]);
+      this.$set(
+        this.items.find((i) => i.subject.id === subject),
+        groups,
+        [{ teachers: [], newCourse: true }],
+      );
     },
     generateTableItems(subjects) {
       return subjects.map((subject) => {
@@ -419,7 +421,9 @@ export default {
             groupCombinations[groupIDs].push({
               ...course,
             });
-          } else if (!groupCombinations[groupIDs].some((c) => c.id === course.id)) {
+          } else if (
+            !groupCombinations[groupIDs].some((c) => c.id === course.id)
+          ) {
             groupCombinations[groupIDs].push({
               ...course,
             });
-- 
GitLab


From 53a38d26be2cb8b64663c6200b142f95e2194e79 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Tue, 14 May 2024 12:02:12 +0200
Subject: [PATCH 10/34] Move filtering of TCCs for ValidityRange to backend

---
 .../TimeboundCourseConfigRaster.vue             | 17 ++++-------------
 .../timeboundCourseConfig.graphql               |  2 ++
 aleksis/apps/lesrooster/schema/__init__.py      |  6 ++++--
 3 files changed, 10 insertions(+), 15 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 3b56b428..8aee25e3 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -264,15 +264,7 @@ export default {
     },
     getCurrentCourseConfig(course) {
       if (course.lrTimeboundCourseConfigs?.length) {
-        let currentCourseConfigs = course.lrTimeboundCourseConfigs.filter(
-          (timeboundConfig) =>
-            timeboundConfig.validityRange.id === this.internalValidityRange.id,
-        );
-        if (currentCourseConfigs.length) {
-          return currentCourseConfigs[0];
-        } else {
-          return null;
-        }
+        return course.lrTimeboundCourseConfigs[0];
       } else {
         return null;
       }
@@ -296,9 +288,7 @@ export default {
         }
       } else {
         if (
-          !course.lrTimeboundCourseConfigs?.filter(
-            (c) => c.validityRange.id === this.internalValidityRange?.id,
-          ).length
+          !course.lrTimeboundCourseConfigs?.length
         ) {
           let existingCreatedCourseConfig = this.createdCourseConfigs.find(
             (c) =>
@@ -507,12 +497,13 @@ export default {
     subjects: {
       query: subjects,
       skip() {
-        return !this.groupIDSet.size || !this.internalValidityRange;
+        return !this.groupIDSet.size || !this.internalValidityRange || !this.persons.length;
       },
       variables() {
         return {
           groups: Array.from(this.groupIDSet),
           includeChildGroups: this.includeChildGroups,
+          validityRange: this.internalValidityRange.id,
         };
       },
       result({ data }) {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index 4f9ca031..64749799 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -103,12 +103,14 @@ query subjects(
   $filters: JSONString
   $groups: [ID]
   $includeChildGroups: Boolean!
+  $validityRange: ID!
 ) {
   subjects: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
     groups: $groups
     includeChildGroups: $includeChildGroups
+    validityRange: $validityRange
   ) {
     ...subjectFields
     courses {
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 26e612a0..50fff730 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -112,6 +112,7 @@ class Query(graphene.ObjectType):
         LesroosterExtendedSubjectType,
         groups=graphene.List(graphene.ID),
         include_child_groups=graphene.Boolean(required=True),
+        validity_range=graphene.ID(required=True)
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -162,14 +163,15 @@ class Query(graphene.ObjectType):
         return Supervision.objects.all()
 
     @staticmethod
-    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
+    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups, validity_range):
         if include_child_groups:
             courses = Course.objects.filter(
                 Q(groups__in=groups) | Q(groups__parent_groups__in=groups)
             )
         else:
             courses = Course.objects.filter(groups__in=groups)
-        courses = get_objects_for_user(info.context.user, "cursus.view_course", courses)
+        course_configs = TimeboundCourseConfig.objects.filter(validity_range=validity_range)
+        courses = get_objects_for_user(info.context.user, "cursus.view_course", courses).prefetch_related(Prefetch("lr_timebound_course_configs", queryset=course_configs))
         subjects = get_objects_for_user(
             info.context.user, "cursus.view_subject", Subject.objects.all()
         )
-- 
GitLab


From 303ce99e0f40b8cc61a947e7f2a7f980e336ee2f Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Tue, 14 May 2024 12:21:59 +0200
Subject: [PATCH 11/34] Move course name generation to backend

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue    | 1 -
 aleksis/apps/lesrooster/schema/timebound_course_config.py      | 3 ++-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index d3c3d5d2..e9a902dd 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -281,7 +281,6 @@ export default {
           this.createdCourses.push({
             subject: subject.id,
             groups: JSON.parse(header.value),
-            name: `${header.text}-${subject.name}`,
             schoolTerm: this.internalValidityRange.schoolTerm.id,
             ...newValue,
           });
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index 1d1e07a5..87426363 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -134,7 +134,8 @@ class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
             teachers = Person.objects.filter(pk__in=course_input.teachers)
 
             course = Course.objects.create(
-                name=course_input.name,
+                name=f"""{''.join(groups.values_list('short_name', flat=True)
+                .order_by('short_name'))}-{subject.name}""",
                 subject=subject,
                 lesson_quota=course_input.lesson_quota or None,
             )
-- 
GitLab


From d4a3d428d593e0dbc6b85f0eb75beae9d1895e89 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Tue, 14 May 2024 13:57:40 +0200
Subject: [PATCH 12/34] Reformat

---
 .../TimeboundCourseConfigRaster.vue                    | 10 ++++++----
 aleksis/apps/lesrooster/schema/__init__.py             | 10 +++++++---
 2 files changed, 13 insertions(+), 7 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index e9a902dd..3e1a63f1 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -288,9 +288,7 @@ export default {
           Object.assign(existingCreatedCourse, newValue);
         }
       } else {
-        if (
-          !course.lrTimeboundCourseConfigs?.length
-        ) {
+        if (!course.lrTimeboundCourseConfigs?.length) {
           let existingCreatedCourseConfig = this.createdCourseConfigs.find(
             (c) =>
               c.course === course.id &&
@@ -498,7 +496,11 @@ export default {
     subjects: {
       query: subjects,
       skip() {
-        return !this.groupIDSet.size || !this.internalValidityRange || !this.persons.length;
+        return (
+          !this.groupIDSet.size ||
+          !this.internalValidityRange ||
+          !this.persons.length
+        );
       },
       variables() {
         return {
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 05c91b5c..354aea19 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -113,7 +113,7 @@ class Query(graphene.ObjectType):
         LesroosterExtendedSubjectType,
         groups=graphene.List(graphene.ID),
         include_child_groups=graphene.Boolean(required=True),
-        validity_range=graphene.ID(required=True)
+        validity_range=graphene.ID(required=True),
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -164,7 +164,9 @@ class Query(graphene.ObjectType):
         return Supervision.objects.all()
 
     @staticmethod
-    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups, validity_range):
+    def resolve_lesrooster_extended_subjects(
+        root, info, groups, include_child_groups, validity_range
+    ):
         if include_child_groups:
             courses = Course.objects.filter(
                 Q(groups__in=groups) | Q(groups__parent_groups__in=groups)
@@ -172,7 +174,9 @@ class Query(graphene.ObjectType):
         else:
             courses = Course.objects.filter(groups__in=groups)
         course_configs = TimeboundCourseConfig.objects.filter(validity_range=validity_range)
-        courses = get_objects_for_user(info.context.user, "cursus.view_course", courses).prefetch_related(Prefetch("lr_timebound_course_configs", queryset=course_configs))
+        courses = get_objects_for_user(
+            info.context.user, "cursus.view_course", courses
+        ).prefetch_related(Prefetch("lr_timebound_course_configs", queryset=course_configs))
         subjects = get_objects_for_user(
             info.context.user, "cursus.view_subject", Subject.objects.all()
         )
-- 
GitLab


From cbccc175cb71b4d3d1edeba8ad7f41b89188a86b Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 00:04:02 +0100
Subject: [PATCH 13/34] Use active school term for filtering TCCs

---
 .../TimeboundCourseConfigRaster.vue                      | 1 -
 .../timeboundCourseConfig.graphql                        | 2 --
 aleksis/apps/lesrooster/schema/__init__.py               | 9 ++++++---
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 7cb125a6..94890795 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -507,7 +507,6 @@ export default {
         return {
           groups: Array.from(this.groupIDSet),
           includeChildGroups: this.includeChildGroups,
-          validityRange: this.internalValidityRange.id,
         };
       },
       result({ data }) {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index b5a2887b..e0fb4042 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -103,14 +103,12 @@ query subjects(
   $filters: JSONString
   $groups: [ID]
   $includeChildGroups: Boolean!
-  $validityRange: ID!
 ) {
   subjects: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
     groups: $groups
     includeChildGroups: $includeChildGroups
-    validityRange: $validityRange
   ) {
     ...subjectFields
     courses {
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index a9e279b6..333e32d1 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -129,7 +129,6 @@ class Query(graphene.ObjectType):
         LesroosterExtendedSubjectType,
         groups=graphene.List(graphene.ID),
         include_child_groups=graphene.Boolean(required=True),
-        validity_range=graphene.ID(required=True),
     )
 
     groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True))
@@ -207,7 +206,7 @@ class Query(graphene.ObjectType):
 
     @staticmethod
     def resolve_lesrooster_extended_subjects(
-        root, info, groups, include_child_groups, validity_range
+        root, info, groups, include_child_groups
     ):
         if include_child_groups:
             courses = Course.objects.filter(
@@ -216,7 +215,11 @@ class Query(graphene.ObjectType):
         else:
             courses = Course.objects.filter(groups__in=groups)
 
-        course_configs = TimeboundCourseConfig.objects.filter(validity_range=validity_range)
+        course_configs = filter_active_school_term(
+            info.context,
+            TimeboundCourseConfig.objects.all(),
+            "validity_range__school_term",
+        )
 
         subjects = Subject.objects.all().prefetch_related(
             Prefetch(
-- 
GitLab


From 340ab9f3d2455178ed3d960c365cf58ce75fe264 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 00:15:54 +0100
Subject: [PATCH 14/34] Remove sending of school term when creating courses in
 TCC raster

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue      | 1 -
 1 file changed, 1 deletion(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 94890795..c855f002 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -281,7 +281,6 @@ export default {
           this.createdCourses.push({
             subject: subject.id,
             groups: JSON.parse(header.value),
-            schoolTerm: this.internalValidityRange.schoolTerm.id,
             name: `${header.text}-${subject.name}`,
             ...newValue,
           });
-- 
GitLab


From ab4c4d61ff6a287665b726234894b48f672baa71 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 12:49:52 +0100
Subject: [PATCH 15/34] Remove unused copy from last VR button

---
 .../TimeboundCourseConfigRaster.vue                 | 13 +------------
 1 file changed, 1 insertion(+), 12 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index c855f002..e1aedaa2 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -74,18 +74,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
           </v-col>
 
           <v-spacer />
-
-          <v-col
-            cols="8"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <secondary-action-button
-              i18n-key="actions.copy_last_configuration"
-              block
-              class="mr-4"
-            />
-          </v-col>
+          
           <v-col
             cols="4"
             lg="1"
-- 
GitLab


From 13f2a00510b90da01b5dc150415e55756e7dcbf0 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 13:06:58 +0100
Subject: [PATCH 16/34] Remove unneeded imports

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue  | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index e1aedaa2..a4cdfa41 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -74,7 +74,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
           </v-col>
 
           <v-spacer />
-          
+
           <v-col
             cols="4"
             lg="1"
@@ -205,12 +205,9 @@ import { currentValidityRange as gqlCurrentValidityRange } from "../validity_ran
 
 import {
   gqlGroupsForPlanning,
-  gqlGroups,
   gqlTeachers,
 } from "../helper.graphql";
 
-import { createCourses } from "aleksis.apps.cursus/components/course.graphql";
-
 export default {
   name: "TimeboungCourseConfigRaster",
   data() {
-- 
GitLab


From 77f05c6e66e79e5f987b94040c9a3cbef567566d Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 13:58:29 +0100
Subject: [PATCH 17/34] =?UTF-8?q?Auto-create=20TCC=20when=20using=20course?=
 =?UTF-8?q?=20p=C3=B6anning=20feature?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../TimeboundCourseConfigRaster.vue           | 14 ++++++-------
 .../timeboundCourseConfig.graphql             |  7 +++++--
 aleksis/apps/lesrooster/schema/__init__.py    |  8 +++----
 .../schema/timebound_course_config.py         | 21 +++++++++++++------
 4 files changed, 30 insertions(+), 20 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index a4cdfa41..c8005e10 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -49,7 +49,6 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
             <validity-range-field
               outlined
               filled
-              label="Select Validity Range"
               hide-details
               v-model="internalValidityRange"
               :loading="$apollo.queries.currentValidityRange.loading"
@@ -198,15 +197,12 @@ import {
   subjects,
   createTimeboundCourseConfigs,
   updateTimeboundCourseConfigs,
-  createCoursesForSchoolTerm,
+  createCoursesForValidityRange,
 } from "./timeboundCourseConfig.graphql";
 
 import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql";
 
-import {
-  gqlGroupsForPlanning,
-  gqlTeachers,
-} from "../helper.graphql";
+import { gqlGroupsForPlanning, gqlTeachers } from "../helper.graphql";
 
 export default {
   name: "TimeboungCourseConfigRaster",
@@ -318,7 +314,7 @@ export default {
         },
         {
           data: this.createdCourses,
-          mutation: createCoursesForSchoolTerm,
+          mutation: createCoursesForValidityRange,
         },
       ]) {
         if (mutationCombination.data.length) {
@@ -327,6 +323,10 @@ export default {
               mutation: mutationCombination.mutation,
               variables: {
                 input: mutationCombination.data,
+                ...(mutationCombination.mutation ===
+                  createCoursesForValidityRange && {
+                  validityRange: this.internalValidityRange.id,
+                }),
               },
             })
             .catch(() => {}); // FIXME Error Handling
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index e0fb4042..cb678603 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -120,8 +120,11 @@ query subjects(
   }
 }
 
-mutation createCoursesForSchoolTerm($input: [CourseInputType]!) {
-  createCoursesForSchoolTerm(input: $input) {
+mutation createCoursesForValidityRange(
+  $input: [CourseInputType]!
+  $validityRange: ID!
+) {
+  createCoursesForValidityRange(input: $input, validityRange: $validityRange) {
     items: courses {
       id
       name
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 333e32d1..4e4fd288 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -58,7 +58,7 @@ from .time_grid import (
 )
 from .timebound_course_bundle import TimeboundCourseBundleType
 from .timebound_course_config import (
-    CourseBatchCreateForSchoolTermMutation,
+    CourseBatchCreateForValidityRangeMutation,
     LesroosterExtendedSubjectType,
     TimeboundCourseConfigBatchCreateMutation,
     TimeboundCourseConfigBatchDeleteMutation,
@@ -205,9 +205,7 @@ class Query(graphene.ObjectType):
         return graphene_django_optimizer.query(qs, info)
 
     @staticmethod
-    def resolve_lesrooster_extended_subjects(
-        root, info, groups, include_child_groups
-    ):
+    def resolve_lesrooster_extended_subjects(root, info, groups, include_child_groups):
         if include_child_groups:
             courses = Course.objects.filter(
                 Q(groups__in=groups) | Q(groups__parent_groups__in=groups)
@@ -449,4 +447,4 @@ class Mutation(graphene.ObjectType):
     delete_supervisions = SupervisionBatchDeleteMutation.Field()
     update_supervisions = SupervisionBatchPatchMutation.Field()
 
-    create_courses_for_school_term = CourseBatchCreateForSchoolTermMutation.Field()
+    create_courses_for_validity_range = CourseBatchCreateForValidityRangeMutation.Field()
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index 52f91d2d..c71d17fe 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -23,7 +23,7 @@ from aleksis.core.util.core_helpers import (
     get_site_preferences,
 )
 
-from ..models import TimeboundCourseConfig
+from ..models import TimeboundCourseConfig, ValidityRange
 
 timebound_course_config_filters = {"course": ["in"], "validity_range": ["in"], "teachers": [""]}
 
@@ -113,18 +113,20 @@ class CourseInputType(graphene.InputObjectType):
     default_room = graphene.ID(required=False)
 
 
-class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
+class CourseBatchCreateForValidityRangeMutation(graphene.Mutation):
     class Arguments:
         input = graphene.List(CourseInputType)
+        validity_range = graphene.ID()
 
     courses = graphene.List(CourseType)
 
     @classmethod
-    def create(cls, info, course_input):
+    def create(cls, info, course_input, validity_range_id):
         if info.context.user.has_perm("cursus.create_course_rule"):
             groups = Group.objects.filter(pk__in=course_input.groups)
             subject = Subject.objects.get(pk=course_input.subject)
             teachers = Person.objects.filter(pk__in=course_input.teachers)
+            validity_range = ValidityRange.objects.get(pk=validity_range_id)
 
             course = Course.objects.create(
                 name=f"""{''.join(groups.values_list('short_name', flat=True)
@@ -134,6 +136,13 @@ class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
             )
             course.teachers.set(teachers)
 
+            tcc = TimeboundCourseConfig.objects.create(
+                course=course,
+                validity_range=validity_range,
+                lesson_quota=course.lesson_quota,
+            )
+            tcc.teachers.set(course.teachers.all())
+
             if get_site_preferences()["lesrooster__create_course_group"]:
                 school_term = get_active_school_term(info.context)
 
@@ -164,6 +173,6 @@ class CourseBatchCreateForSchoolTermMutation(graphene.Mutation):
             return course
 
     @classmethod
-    def mutate(cls, root, info, input):  # noqa
-        objs = [cls.create(info, course_input) for course_input in input]
-        return CourseBatchCreateForSchoolTermMutation(courses=objs)
+    def mutate(cls, root, info, input, validity_range):  # noqa
+        objs = [cls.create(info, course_input, validity_range) for course_input in input]
+        return CourseBatchCreateForValidityRangeMutation(courses=objs)
-- 
GitLab


From 6b87f2acd74e46968acd1c027f5ff45717a416c5 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 13:58:46 +0100
Subject: [PATCH 18/34] Set translated default label for VR field

---
 .../frontend/components/validity_range/ValidityRangeField.vue    | 1 +
 1 file changed, 1 insertion(+)

diff --git a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
index 5be88f33..8ff63e95 100644
--- a/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
+++ b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue
@@ -9,6 +9,7 @@ import DateField from "aleksis.core/components/generic/forms/DateField.vue";
     v-on="$listeners"
     :fields="headers"
     create-item-i18n-key="lesrooster.validity_range.create_validity_range"
+    :label="$t('labels.select_validity_range')"
     :gql-query="gqlQuery"
     :gql-create-mutation="gqlCreateMutation"
     :gql-patch-mutation="{}"
-- 
GitLab


From 4ac86f4fbebabc659c4225ece323664a4f5a7d07 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 29 Jan 2025 14:44:22 +0100
Subject: [PATCH 19/34] Avoid refetch after saving by updating subjects cache
 with results

---
 .../TimeboundCourseConfigRaster.vue           | 62 +++++++++++++++----
 .../timeboundCourseConfig.graphql             | 16 ++---
 .../schema/timebound_course_config.py         |  2 +-
 3 files changed, 54 insertions(+), 26 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index c8005e10..47a88f43 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -204,8 +204,11 @@ import { currentValidityRange as gqlCurrentValidityRange } from "../validity_ran
 
 import { gqlGroupsForPlanning, gqlTeachers } from "../helper.graphql";
 
+import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
+
 export default {
   name: "TimeboungCourseConfigRaster",
+  mixins: [mutateMixin],
   data() {
     return {
       i18nKey: "lesrooster.timebound_course_config",
@@ -300,6 +303,38 @@ export default {
         }
       }
     },
+    updateCourseConfigs(cachedSubjects, incomingCourseConfigs) {
+      incomingCourseConfigs.forEach((newCourseConfig) => {
+        const subject = cachedSubjects.find(
+          (s) => s.id === newCourseConfig.course.subject.id,
+        );
+        if (!subject) {
+          return;
+        }
+
+        const course = subject.courses.find(
+          (c) => c.id === newCourseConfig.course.id,
+        );
+
+        course.lrTimeboundCourseConfigs = [newCourseConfig];
+      });
+
+      return cachedSubjects;
+    },
+    updateCreatedCourses(cachedSubjects, incomingCourses) {
+      incomingCourses.forEach((newCourse) => {
+        const subject = cachedSubjects.find(
+          (s) => s.id === newCourse.subject.id,
+        );
+        if (!subject) {
+          return;
+        }
+
+        subject.courses.push(newCourse);
+      });
+
+      return cachedSubjects;
+    },
     save() {
       this.loading = true;
 
@@ -307,36 +342,37 @@ export default {
         {
           data: this.editedCourseConfigs,
           mutation: updateTimeboundCourseConfigs,
+          updateCacheHandler: this.updateCourseConfigs,
         },
         {
           data: this.createdCourseConfigs,
           mutation: createTimeboundCourseConfigs,
+          updateCacheHandler: this.updateCourseConfigs,
         },
         {
           data: this.createdCourses,
           mutation: createCoursesForValidityRange,
+          updateCacheHandler: this.updateCreatedCourses,
         },
       ]) {
         if (mutationCombination.data.length) {
-          this.$apollo
-            .mutate({
-              mutation: mutationCombination.mutation,
-              variables: {
-                input: mutationCombination.data,
-                ...(mutationCombination.mutation ===
-                  createCoursesForValidityRange && {
-                  validityRange: this.internalValidityRange.id,
-                }),
-              },
-            })
-            .catch(() => {}); // FIXME Error Handling
+          this.mutate(
+            mutationCombination.mutation,
+            {
+              input: mutationCombination.data,
+              ...(mutationCombination.mutation ===
+                createCoursesForValidityRange && {
+                validityRange: this.internalValidityRange.id,
+              }),
+            },
+            mutationCombination.updateCacheHandler,
+          );
         }
       }
 
       this.editedCourseConfigs = [];
       this.createdCourseConfigs = [];
       this.createdCourses = [];
-      this.$apollo.queries.subjects.refetch();
       this.loading = false;
     },
     getTeacherList(subjectTeachers) {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index cb678603..5f86c9a8 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -126,21 +126,13 @@ mutation createCoursesForValidityRange(
 ) {
   createCoursesForValidityRange(input: $input, validityRange: $validityRange) {
     items: courses {
-      id
-      name
+      ...courseFields
       subject {
-        id
-        name
-      }
-      teachers {
-        id
-        fullName
+        ...subjectFields
       }
-      groups {
-        id
-        name
+      lrTimeboundCourseConfigs {
+        ...timeboundCourseConfigFields
       }
-      lessonQuota
       canEdit
       canDelete
     }
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index c71d17fe..eb765f02 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -118,7 +118,7 @@ class CourseBatchCreateForValidityRangeMutation(graphene.Mutation):
         input = graphene.List(CourseInputType)
         validity_range = graphene.ID()
 
-    courses = graphene.List(CourseType)
+    courses = graphene.List(LesroosterExtendedCourseType)
 
     @classmethod
     def create(cls, info, course_input, validity_range_id):
-- 
GitLab


From 333c55fc366386efb18c25e5106be42288345661 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 27 Feb 2025 00:33:50 +0100
Subject: [PATCH 20/34] Implement TCC raster autosave

---
 .../TimeboundCourseConfigRaster.vue           | 146 ++++++++++--------
 .../timeboundCourseConfig.graphql             |   2 +-
 2 files changed, 83 insertions(+), 65 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 47a88f43..4e4542f7 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -1,7 +1,5 @@
 <script setup>
 import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
-import SaveButton from "aleksis.core/components/generic/buttons/SaveButton.vue";
-import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue";
 import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
 import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
 </script>
@@ -15,7 +13,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
       hide-default-footer
       :headers="headers"
       :items="items"
-      :loading="$apollo.queries.subjects.loading"
+      :loading="loading"
     >
       <template #top>
         <v-row>
@@ -73,22 +71,6 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
           </v-col>
 
           <v-spacer />
-
-          <v-col
-            cols="4"
-            lg="1"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <save-button
-              :disabled="
-                !editedCourseConfigs.length &&
-                !createdCourseConfigs.length &&
-                !createdCourses.length
-              "
-              :loading="loading"
-              @click="save"
-            />
-          </v-col>
         </v-row>
       </template>
 
@@ -237,7 +219,6 @@ export default {
       createdCourses: [],
       currentCourse: null,
       currentSubject: null,
-      loading: false,
       includeChildGroups: true,
       selectedGroupHeaders: [],
       items: [],
@@ -270,7 +251,9 @@ export default {
             ...newValue,
           });
         } else {
-          Object.assign(existingCreatedCourse, newValue);
+          for (const key in newValue) {
+            this.$set(existingCreatedCourse, key, newValue[key]);
+          }
         }
       } else {
         if (!course.lrTimeboundCourseConfigs?.length) {
@@ -288,7 +271,9 @@ export default {
               ...newValue,
             });
           } else {
-            Object.assign(existingCreatedCourseConfig, newValue);
+            for (const key in newValue) {
+              this.$set(existingCreatedCourseConfig, key, newValue[key]);
+            }
           }
         } else {
           let courseConfigID = course.lrTimeboundCourseConfigs[0].id;
@@ -298,7 +283,9 @@ export default {
           if (!existingEditedCourseConfig) {
             this.editedCourseConfigs.push({ id: courseConfigID, ...newValue });
           } else {
-            Object.assign(existingEditedCourseConfig, newValue);
+            for (const key in newValue) {
+              this.$set(existingEditedCourseConfig, key, newValue[key]);
+            }
           }
         }
       }
@@ -335,46 +322,6 @@ export default {
 
       return cachedSubjects;
     },
-    save() {
-      this.loading = true;
-
-      for (let mutationCombination of [
-        {
-          data: this.editedCourseConfigs,
-          mutation: updateTimeboundCourseConfigs,
-          updateCacheHandler: this.updateCourseConfigs,
-        },
-        {
-          data: this.createdCourseConfigs,
-          mutation: createTimeboundCourseConfigs,
-          updateCacheHandler: this.updateCourseConfigs,
-        },
-        {
-          data: this.createdCourses,
-          mutation: createCoursesForValidityRange,
-          updateCacheHandler: this.updateCreatedCourses,
-        },
-      ]) {
-        if (mutationCombination.data.length) {
-          this.mutate(
-            mutationCombination.mutation,
-            {
-              input: mutationCombination.data,
-              ...(mutationCombination.mutation ===
-                createCoursesForValidityRange && {
-                validityRange: this.internalValidityRange.id,
-              }),
-            },
-            mutationCombination.updateCacheHandler,
-          );
-        }
-      }
-
-      this.editedCourseConfigs = [];
-      this.createdCourseConfigs = [];
-      this.createdCourses = [];
-      this.loading = false;
-    },
     getTeacherList(subjectTeachers) {
       return [
         {
@@ -491,6 +438,27 @@ export default {
     subjectGroupCombinations() {
       return Array.from(this.groupCombinationsSet);
     },
+    createdCoursesReady() {
+      return !!this.createdCourses.length && this.createdCourses.every((c) => {
+        return c?.groups?.length && c.lessonQuota && c.name && c.subject && c?.teachers?.length
+      });
+    },
+    createdCourseConfigsReady() {
+      return !!this.createdCourseConfigs.length && this.createdCourseConfigs.every((c) => {
+        return c.course && c.validityRange && c?.teachers?.length && c.lessonQuota
+      });
+    },
+    editedCourseConfigsReady() {
+      return !!this.editedCourseConfigs.length && this.editedCourseConfigs.every((c) => {
+        return c.id && (c.lessonQuota || c?.teachers?.length);
+      });
+    },
+    expandedQuery() {
+      return {
+        ...this.$apollo.queries.subjects.options,
+        variables: JSON.parse(this.$apollo.queries.subjects.previousVariablesJson),
+      };
+    },
   },
   watch: {
     selectedGroups(newValue) {
@@ -499,6 +467,55 @@ export default {
         value: JSON.stringify([group.id]),
       }));
     },
+    editedCourseConfigs: {
+      deep: true,
+      handler(newValue) {
+        if (this.editedCourseConfigsReady) {
+          this.mutate(
+            updateTimeboundCourseConfigs,
+            {
+              input: newValue,
+            },
+            this.updateCourseConfigs,
+          );
+
+          this.editedCourseConfigs = [];
+        }
+      },
+    },
+    createdCourseConfigs: {
+      deep: true,
+      handler(newValue) {
+        if (this.createdCourseConfigsReady) {
+          this.mutate(
+            createTimeboundCourseConfigs,
+            {
+              input: newValue,
+            },
+            this.updateCourseConfigs,
+          );
+
+          this.createdCourseConfigs = [];
+        }
+      },
+    },
+    createdCourses: {
+      deep: true,
+      handler(newValue) {
+        if (this.createdCoursesReady) {
+          this.mutate(
+            createCoursesForValidityRange,
+            {
+              input: newValue,
+              validityRange: this.internalValidityRange.id,
+            },
+            this.updateCreatedCourses,
+          );
+
+          this.createdCourses = [];
+        }
+      },
+    }
   },
   apollo: {
     currentValidityRange: {
@@ -530,9 +547,10 @@ export default {
           includeChildGroups: this.includeChildGroups,
         };
       },
+      update: data => data.items,
       result({ data }) {
         if (!data) return;
-        this.items = this.generateTableItems(data.subjects);
+        this.items = this.generateTableItems(data.items);
       },
     },
     persons: {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
index 5f86c9a8..15d3954d 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/timeboundCourseConfig.graphql
@@ -104,7 +104,7 @@ query subjects(
   $groups: [ID]
   $includeChildGroups: Boolean!
 ) {
-  subjects: lesroosterExtendedSubjects(
+  items: lesroosterExtendedSubjects(
     orderBy: $orderBy
     filters: $filters
     groups: $groups
-- 
GitLab


From ee768cd2a060de0fd9fcd5fe6424d8f510d9b2cf Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Fri, 28 Feb 2025 19:54:49 +0100
Subject: [PATCH 21/34] Make lesson quota field required & fire setting course
 data only on change

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 4e4542f7..4d4718c2 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -103,7 +103,8 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
                       : course.lessonQuota
                   "
                   :label="$t('lesrooster.timebound_course_config.lesson_quota')"
-                  @input="
+                  :rules="$rules().required.build()"
+                  @change="
                     (event) =>
                       setCourseConfigData(course, item.subject, header, {
                         lessonQuota: event,
@@ -187,10 +188,11 @@ import { currentValidityRange as gqlCurrentValidityRange } from "../validity_ran
 import { gqlGroupsForPlanning, gqlTeachers } from "../helper.graphql";
 
 import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
 
 export default {
   name: "TimeboungCourseConfigRaster",
-  mixins: [mutateMixin],
+  mixins: [formRulesMixin, mutateMixin],
   data() {
     return {
       i18nKey: "lesrooster.timebound_course_config",
-- 
GitLab


From 7f4c51c2ac535d51fcf2d8780b9c1dfa9ed83ddf Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Fri, 28 Feb 2025 20:03:07 +0100
Subject: [PATCH 22/34] Make lessonQuota not required

---
 .../TimeboundCourseConfigRaster.vue                        | 7 +++----
 1 file changed, 3 insertions(+), 4 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 4d4718c2..7afbc0eb 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -103,7 +103,6 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
                       : course.lessonQuota
                   "
                   :label="$t('lesrooster.timebound_course_config.lesson_quota')"
-                  :rules="$rules().required.build()"
                   @change="
                     (event) =>
                       setCourseConfigData(course, item.subject, header, {
@@ -442,17 +441,17 @@ export default {
     },
     createdCoursesReady() {
       return !!this.createdCourses.length && this.createdCourses.every((c) => {
-        return c?.groups?.length && c.lessonQuota && c.name && c.subject && c?.teachers?.length
+        return c?.groups?.length && c.name && c.subject && c?.teachers?.length
       });
     },
     createdCourseConfigsReady() {
       return !!this.createdCourseConfigs.length && this.createdCourseConfigs.every((c) => {
-        return c.course && c.validityRange && c?.teachers?.length && c.lessonQuota
+        return c.course && c.validityRange && c?.teachers?.length
       });
     },
     editedCourseConfigsReady() {
       return !!this.editedCourseConfigs.length && this.editedCourseConfigs.every((c) => {
-        return c.id && (c.lessonQuota || c?.teachers?.length);
+        return c.id && (Object.hasOwn(c, "lessonQuota") || c?.teachers?.length);
       });
     },
     expandedQuery() {
-- 
GitLab


From 548e9d71bd43c76f99e1df3e5dcd55759bc07b86 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Fri, 28 Feb 2025 20:22:54 +0100
Subject: [PATCH 23/34] Use TeacherField instead of custom autocomplete

---
 .../TimeboundCourseConfigRaster.vue           | 57 +++----------------
 1 file changed, 7 insertions(+), 50 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 7afbc0eb..ddcad459 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -2,6 +2,7 @@
 import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
 import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
 import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
+import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
 </script>
 
 <template>
@@ -112,14 +113,9 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
                 />
               </v-col>
               <v-col cols="6">
-                <v-autocomplete
-                  counter
+                <teacher-field
                   dense
                   filled
-                  multiple
-                  :items="getTeacherList(item.subject.teachers)"
-                  item-text="fullName"
-                  item-value="id"
                   class="mx-1"
                   :disabled="loading"
                   :label="$t('lesrooster.timebound_course_config.teachers')"
@@ -128,32 +124,15 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
                       ? getCurrentCourseConfig(course).teachers
                       : course.teachers
                   "
+                  :show-subjects="true"
+                  :priority-subject="item.subject"
                   @input="
                     (event) =>
                       setCourseConfigData(course, item.subject, header, {
                         teachers: event,
                       })
                   "
-                >
-                  <template #item="data">
-                    <template v-if="typeof data.item !== 'object'">
-                      <v-list-item-content>{{ data.item }}</v-list-item-content>
-                    </template>
-                    <template v-else>
-                      <v-list-item-action>
-                        <v-checkbox v-model="data.attrs.inputValue" />
-                      </v-list-item-action>
-                      <v-list-item-content>
-                        <v-list-item-title>{{
-                          data.item.fullName
-                        }}</v-list-item-title>
-                        <v-list-item-subtitle v-if="data.item.shortName">{{
-                          data.item.shortName
-                        }}</v-list-item-subtitle>
-                      </v-list-item-content>
-                    </template>
-                  </template>
-                </v-autocomplete>
+                />
               </v-col>
             </v-row>
           </div>
@@ -184,7 +163,7 @@ import {
 
 import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql";
 
-import { gqlGroupsForPlanning, gqlTeachers } from "../helper.graphql";
+import { gqlGroupsForPlanning } from "../helper.graphql";
 
 import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
 import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
@@ -323,24 +302,6 @@ export default {
 
       return cachedSubjects;
     },
-    getTeacherList(subjectTeachers) {
-      return [
-        {
-          header: this.$t(
-            "lesrooster.timebound_course_config.subject_teachers",
-          ),
-        },
-        ...this.persons.filter((person) =>
-          subjectTeachers.find((teacher) => teacher.id === person.id),
-        ),
-        { divider: true },
-        { header: this.$t("lesrooster.timebound_course_config.all_teachers") },
-        ...this.persons.filter(
-          (person) =>
-            !subjectTeachers.find((teacher) => teacher.id === person.id),
-        ),
-      ];
-    },
     addCourse(subject, groups) {
       this.$set(
         this.items.find((i) => i.subject.id === subject),
@@ -538,8 +499,7 @@ export default {
       skip() {
         return (
           !this.groupIDSet.size ||
-          !this.internalValidityRange ||
-          !this.persons.length
+          !this.internalValidityRange
         );
       },
       variables() {
@@ -554,9 +514,6 @@ export default {
         this.items = this.generateTableItems(data.items);
       },
     },
-    persons: {
-      query: gqlTeachers,
-    },
   },
 };
 </script>
-- 
GitLab


From c58789ee625a12c38885dd4fca4e8fca04b9b4f7 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Fri, 28 Feb 2025 20:23:03 +0100
Subject: [PATCH 24/34] Make TeacherField required

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue      | 1 +
 1 file changed, 1 insertion(+)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index ddcad459..5c9feed7 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -126,6 +126,7 @@ import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
                   "
                   :show-subjects="true"
                   :priority-subject="item.subject"
+                  :rules="$rules().isNonEmpty.build()"
                   @input="
                     (event) =>
                       setCourseConfigData(course, item.subject, header, {
-- 
GitLab


From f0c2b7f0f77c051d6c49718ce837944f4b43fd5a Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Fri, 28 Feb 2025 21:02:13 +0100
Subject: [PATCH 25/34] Properly set data table loading state

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue    | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 5c9feed7..9eb63604 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -422,6 +422,9 @@ export default {
         variables: JSON.parse(this.$apollo.queries.subjects.previousVariablesJson),
       };
     },
+    tableLoading() {
+      return this.loading || this.$apollo.queries.subjects.loading || this.$apollo.queries.groupsForPlanning.loading || this.$apollo.queries.currentValidityRange.loading;
+    },
   },
   watch: {
     selectedGroups(newValue) {
-- 
GitLab


From 1982f79ffa1b021bbc201bee9256868214e6b4bd Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Wed, 5 Mar 2025 08:26:48 +0100
Subject: [PATCH 26/34] Move permission check

---
 aleksis/apps/lesrooster/schema/__init__.py               | 9 ++++++++-
 .../apps/lesrooster/schema/timebound_course_config.py    | 8 ++------
 2 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 4e4fd288..dc4ea901 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -217,7 +217,14 @@ class Query(graphene.ObjectType):
             info.context,
             TimeboundCourseConfig.objects.all(),
             "validity_range__school_term",
-        )
+        )        
+        if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
+            course_configs = get_objects_for_user(
+                info.context.user,
+                "lesrooster.view_timeboundcourseconfig",
+                course_configs,
+            )
+
 
         subjects = Subject.objects.all().prefetch_related(
             Prefetch(
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index eb765f02..1e96d35d 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -1,3 +1,5 @@
+import time
+
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
@@ -67,12 +69,6 @@ class LesroosterExtendedCourseType(CourseType):
 
     @staticmethod
     def resolve_lr_timebound_course_configs(root, info, **kwargs):
-        if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
-            return get_objects_for_user(
-                info.context.user,
-                "lesrooster.view_timeboundcourseconfig",
-                root.lr_timebound_course_configs.all(),
-            )
         return root.lr_timebound_course_configs.all()
 
 
-- 
GitLab


From 29e259cabbf6d75c7008d748e8f6e89e5bd09d71 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 6 Mar 2025 23:44:45 +0100
Subject: [PATCH 27/34] Speed TCC raster up by refactoring components and using
 v-lazy

---
 .../TimeboundCourseConfigRaster.vue           | 330 ++++++++++--------
 .../TimeboundCourseConfigRasterCell.vue       | 121 +++++++
 2 files changed, 299 insertions(+), 152 deletions(-)
 create mode 100644 aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 9eb63604..af322060 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -1,157 +1,187 @@
 <script setup>
-import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
 import ValidityRangeField from "../validity_range/ValidityRangeField.vue";
 import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue";
-import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
+import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.vue";
 </script>
 
 <template>
-  <div>
-    <v-data-table
-      disable-sort
-      disable-filtering
-      disable-pagination
-      hide-default-footer
-      :headers="headers"
-      :items="items"
-      :loading="loading"
-    >
-      <template #top>
-        <v-row>
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <v-autocomplete
-              outlined
-              filled
-              multiple
-              hide-details
-              :items="groupsForPlanning"
-              item-text="shortName"
-              item-value="id"
-              return-object
-              :disabled="$apollo.queries.groupsForPlanning.loading"
-              :label="$t('lesrooster.timebound_course_config.groups')"
-              :loading="$apollo.queries.groupsForPlanning.loading"
-              v-model="selectedGroups"
-              class="mr-4"
-            />
-          </v-col>
+  <v-data-table
+    disable-sort
+    disable-filtering
+    disable-pagination
+    hide-default-footer
+    :headers="headers"
+    :items="items"
+    :loading="loading"
+  >
+    <template #top>
+      <v-row>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-autocomplete
+            outlined
+            filled
+            multiple
+            hide-details
+            :items="groupsForPlanning"
+            item-text="shortName"
+            item-value="id"
+            return-object
+            :disabled="$apollo.queries.groupsForPlanning.loading"
+            :label="$t('lesrooster.timebound_course_config.groups')"
+            :loading="$apollo.queries.groupsForPlanning.loading"
+            v-model="selectedGroups"
+            class="mr-4"
+          />
+        </v-col>
 
-          <v-col
-            cols="6"
-            lg="3"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <validity-range-field
-              outlined
-              filled
-              hide-details
-              v-model="internalValidityRange"
-              :loading="$apollo.queries.currentValidityRange.loading"
-            />
-          </v-col>
+        <v-col
+          cols="6"
+          lg="3"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <validity-range-field
+            outlined
+            filled
+            hide-details
+            v-model="internalValidityRange"
+            :loading="$apollo.queries.currentValidityRange.loading"
+          />
+        </v-col>
 
-          <v-col
-            cols="6"
-            lg="2"
-            class="d-flex justify-space-between flex-wrap align-center"
-          >
-            <v-switch
-              v-model="includeChildGroups"
-              inset
-              :label="
-                $t(
-                  'lesrooster.timebound_course_config.filters.include_child_groups',
-                )
-              "
-              :loading="$apollo.queries.subjects.loading"
-            ></v-switch>
-          </v-col>
+        <v-col
+          cols="6"
+          lg="2"
+          class="d-flex justify-space-between flex-wrap align-center"
+        >
+          <v-switch
+            v-model="includeChildGroups"
+            inset
+            :label="
+              $t(
+                'lesrooster.timebound_course_config.filters.include_child_groups',
+              )
+            "
+            :loading="$apollo.queries.subjects.loading"
+          ></v-switch>
+        </v-col>
 
-          <v-spacer />
-        </v-row>
-      </template>
+        <v-spacer />
+      </v-row>
+    </template>
 
-      <!-- eslint-disable-next-line vue/valid-v-slot -->
-      <template #item.subject="{ item, value }">
-        <subject-chip v-if="value" :subject="value" />
-      </template>
+    <!-- eslint-disable-next-line vue/valid-v-slot -->
+    <template #item.subject="{ item, value }">
+      <subject-chip v-if="value" :subject="value" />
+    </template>
 
-      <template
-        v-for="(groupHeader, index) in groupHeaders"
-        #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+    <template
+      v-for="(groupHeader, index) in groupHeaders"
+      #[tableItemSlotName(groupHeader)]="{ item, value, header }"
+    >
+      <timebound-course-config-raster-cell
+        :value="value"
+        :subject="item.subject"
+        :header="header"
+        :loading="loading"
+        @addCourse="addCourse"
+        @setCourseConfigData="setCourseConfigData"
+      />
+    </template>
+  </v-data-table>
+
+  <!-- <div>
+    <v-row>
+      <v-col
+        cols="6"
+        lg="3"
+        class="d-flex justify-space-between flex-wrap align-center"
+      >
+        <v-autocomplete
+          outlined
+          filled
+          multiple
+          hide-details
+          :items="groupsForPlanning"
+          item-text="shortName"
+          item-value="id"
+          return-object
+          :disabled="$apollo.queries.groupsForPlanning.loading"
+          :label="$t('lesrooster.timebound_course_config.groups')"
+          :loading="$apollo.queries.groupsForPlanning.loading"
+          v-model="selectedGroups"
+          class="mr-4"
+        />
+      </v-col>
+
+      <v-col
+        cols="6"
+        lg="3"
+        class="d-flex justify-space-between flex-wrap align-center"
       >
-        <div :key="index">
-          <div v-if="value.length">
-            <v-row
-              v-for="(course, index) in value"
-              :key="index"
-              no-gutters
-              class="mt-2"
-            >
-              <v-col cols="6">
-                <positive-small-integer-field
-                  dense
-                  filled
-                  class="mx-1"
-                  :disabled="loading"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).lessonQuota
-                      : course.lessonQuota
-                  "
-                  :label="$t('lesrooster.timebound_course_config.lesson_quota')"
-                  @change="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        lessonQuota: event,
-                      })
-                  "
-                />
-              </v-col>
-              <v-col cols="6">
-                <teacher-field
-                  dense
-                  filled
-                  class="mx-1"
-                  :disabled="loading"
-                  :label="$t('lesrooster.timebound_course_config.teachers')"
-                  :value="
-                    getCurrentCourseConfig(course)
-                      ? getCurrentCourseConfig(course).teachers
-                      : course.teachers
-                  "
-                  :show-subjects="true"
-                  :priority-subject="item.subject"
-                  :rules="$rules().isNonEmpty.build()"
-                  @input="
-                    (event) =>
-                      setCourseConfigData(course, item.subject, header, {
-                        teachers: event,
-                      })
-                  "
-                />
-              </v-col>
-            </v-row>
-          </div>
-          <div v-if="!value.length">
-            <v-btn
-              block
-              icon
-              tile
-              outlined
-              @click="addCourse(item.subject.id, header.value)"
-            >
-              <v-icon>mdi-plus</v-icon>
-            </v-btn>
-          </div>
-        </div>
-      </template>
-    </v-data-table>
-  </div>
+        <validity-range-field
+          outlined
+          filled
+          hide-details
+          v-model="internalValidityRange"
+          :loading="$apollo.queries.currentValidityRange.loading"
+        />
+      </v-col>
+
+      <v-col
+        cols="6"
+        lg="2"
+        class="d-flex justify-space-between flex-wrap align-center"
+      >
+        <v-switch
+          v-model="includeChildGroups"
+          inset
+          :label="
+            $t(
+              'lesrooster.timebound_course_config.filters.include_child_groups',
+            )
+          "
+          :loading="$apollo.queries.subjects.loading"
+        ></v-switch>
+      </v-col>
+
+      <v-spacer />
+    </v-row>
+    <v-simple-table
+      fixed-header
+    >
+      <thead>
+        <tr>
+          <th v-for="header in headers" :key="header.value" class="text-left">
+            {{ header.text }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr
+          v-for="(item, index) in items"
+          :key="index"
+        >
+          <td>
+            <subject-chip :subject="item.subject" />
+          </td>
+          <td v-for="header in groupHeaders" :key="header.value">
+            <timebound-course-config-raster-cell
+              :value="item[header.value]"
+              :subject="item.subject"
+              :header="header"
+              :loading="loading"
+              @addCourse="addCourse"
+              @setCourseConfigData="setCourseConfigData"
+            />
+          </td>
+        </tr>
+      </tbody>
+    </v-simple-table>
+  </div> -->
 </template>
 
 <script>
@@ -167,11 +197,10 @@ import { currentValidityRange as gqlCurrentValidityRange } from "../validity_ran
 import { gqlGroupsForPlanning } from "../helper.graphql";
 
 import mutateMixin from "aleksis.core/mixins/mutateMixin.js";
-import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
 
 export default {
   name: "TimeboungCourseConfigRaster",
-  mixins: [formRulesMixin, mutateMixin],
+  mixins: [mutateMixin],
   data() {
     return {
       i18nKey: "lesrooster.timebound_course_config",
@@ -210,13 +239,6 @@ export default {
     tableItemSlotName(header) {
       return "item." + header.value;
     },
-    getCurrentCourseConfig(course) {
-      if (course.lrTimeboundCourseConfigs?.length) {
-        return course.lrTimeboundCourseConfigs[0];
-      } else {
-        return null;
-      }
-    },
     setCourseConfigData(course, subject, header, newValue) {
       if (course.newCourse) {
         let existingCreatedCourse = this.createdCourses.find(
@@ -311,7 +333,8 @@ export default {
       );
     },
     generateTableItems(subjects) {
-      return subjects.map((subject) => {
+      const start = performance.now();
+      const subjectsWithSortedCourses = subjects.map((subject) => {
         let { courses, ...reducedSubject } = subject;
         let groupCombinations = {};
 
@@ -359,6 +382,9 @@ export default {
           ...groupCombinations,
         };
       });
+      const end = performance.now();
+      console.log(`Execution time: ${end - start} ms`);
+      return subjectsWithSortedCourses;
     },
   },
   computed: {
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
new file mode 100644
index 00000000..71efac96
--- /dev/null
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
@@ -0,0 +1,121 @@
+<script setup>
+import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
+import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
+</script>
+
+<template>
+  <v-lazy
+    v-model="active"
+    :options="{
+      threshold: .5
+    }"
+    transition="fade-transition"
+  >
+    <div v-if="value.length">
+      <v-row
+        v-for="(course, index) in value"
+        :key="index"
+        no-gutters
+        class="mt-2"
+      >
+        <v-col cols="6">
+          <positive-small-integer-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).lessonQuota
+                : course.lessonQuota
+            "
+            :label="$t('lesrooster.timebound_course_config.lesson_quota')"
+            @change="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  lessonQuota: event,
+                })
+            "
+          />
+        </v-col>
+        <v-col cols="6">
+          <teacher-field
+            dense
+            filled
+            class="mx-1"
+            :disabled="loading"
+            :label="$t('lesrooster.timebound_course_config.teachers')"
+            :value="
+              getCurrentCourseConfig(course)
+                ? getCurrentCourseConfig(course).teachers
+                : course.teachers
+            "
+            :show-subjects="true"
+            :priority-subject="subject"
+            :rules="$rules().isNonEmpty.build()"
+            @input="
+              (event) =>
+                $emit('setCourseConfigData', course, subject, header, {
+                  teachers: event,
+                })
+            "
+          />
+        </v-col>
+      </v-row>
+    </div>
+    <div v-else>
+      <v-btn
+        block
+        icon
+        tile
+        outlined
+        @click="$emit('addCourse', subject.id, header.value)"
+      >
+        <v-icon>mdi-plus</v-icon>
+      </v-btn>
+    </div>
+  </v-lazy>
+</template>
+
+<script>
+import formRulesMixin from "aleksis.core/mixins/formRulesMixin";
+
+export default {
+  name: "TimeboundCourseConfigRasterCell",
+  mixins: [formRulesMixin],
+  emits: ["addCourse", "setCourseConfigData"],
+  props: {
+    value: {
+      type: Array,
+      required: true,
+    },
+    subject: {
+      type: Object,
+      required: true,
+    },
+    header: {
+      type: Object,
+      required: true,
+    },
+    loading: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
+  },
+  data() {
+    return {
+      active: false,
+    };
+  },
+  methods: {
+    getCurrentCourseConfig(course) {
+      if (course.lrTimeboundCourseConfigs?.length) {
+        return course.lrTimeboundCourseConfigs[0];
+      } else {
+        return null;
+      }
+    },
+  },
+};
+</script>
-- 
GitLab


From 2edc01bf382ade60f6c9bcb6d77aa0fa0949d4dd Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 16:45:27 +0200
Subject: [PATCH 28/34] Rename VR ID function argument

---
 aleksis/apps/lesrooster/schema/timebound_course_config.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index 1e96d35d..ae485171 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -117,12 +117,12 @@ class CourseBatchCreateForValidityRangeMutation(graphene.Mutation):
     courses = graphene.List(LesroosterExtendedCourseType)
 
     @classmethod
-    def create(cls, info, course_input, validity_range_id):
+    def create(cls, info, course_input, validity_range):
         if info.context.user.has_perm("cursus.create_course_rule"):
             groups = Group.objects.filter(pk__in=course_input.groups)
             subject = Subject.objects.get(pk=course_input.subject)
             teachers = Person.objects.filter(pk__in=course_input.teachers)
-            validity_range = ValidityRange.objects.get(pk=validity_range_id)
+            validity_range = ValidityRange.objects.get(pk=validity_range)
 
             course = Course.objects.create(
                 name=f"""{''.join(groups.values_list('short_name', flat=True)
-- 
GitLab


From 744ebb137981306826ff0f3575eb3fdc2cfaa20a Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 16:47:46 +0200
Subject: [PATCH 29/34] Re-add perm check

---
 aleksis/apps/lesrooster/schema/timebound_course_config.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index ae485171..f3f93d86 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -69,7 +69,9 @@ class LesroosterExtendedCourseType(CourseType):
 
     @staticmethod
     def resolve_lr_timebound_course_configs(root, info, **kwargs):
-        return root.lr_timebound_course_configs.all()
+        if info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
+            return root.lr_timebound_course_configs.all()
+        return []
 
 
 class LesroosterExtendedSubjectType(SubjectType):
-- 
GitLab


From bc78eedf05d7f837147396370459e54cbfdd7d0b Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 16:48:31 +0200
Subject: [PATCH 30/34] Drop commented out code sections

---
 .../TimeboundCourseConfigRaster.vue           | 90 -------------------
 1 file changed, 90 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index af322060..cef13fe7 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -92,96 +92,6 @@ import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.v
       />
     </template>
   </v-data-table>
-
-  <!-- <div>
-    <v-row>
-      <v-col
-        cols="6"
-        lg="3"
-        class="d-flex justify-space-between flex-wrap align-center"
-      >
-        <v-autocomplete
-          outlined
-          filled
-          multiple
-          hide-details
-          :items="groupsForPlanning"
-          item-text="shortName"
-          item-value="id"
-          return-object
-          :disabled="$apollo.queries.groupsForPlanning.loading"
-          :label="$t('lesrooster.timebound_course_config.groups')"
-          :loading="$apollo.queries.groupsForPlanning.loading"
-          v-model="selectedGroups"
-          class="mr-4"
-        />
-      </v-col>
-
-      <v-col
-        cols="6"
-        lg="3"
-        class="d-flex justify-space-between flex-wrap align-center"
-      >
-        <validity-range-field
-          outlined
-          filled
-          hide-details
-          v-model="internalValidityRange"
-          :loading="$apollo.queries.currentValidityRange.loading"
-        />
-      </v-col>
-
-      <v-col
-        cols="6"
-        lg="2"
-        class="d-flex justify-space-between flex-wrap align-center"
-      >
-        <v-switch
-          v-model="includeChildGroups"
-          inset
-          :label="
-            $t(
-              'lesrooster.timebound_course_config.filters.include_child_groups',
-            )
-          "
-          :loading="$apollo.queries.subjects.loading"
-        ></v-switch>
-      </v-col>
-
-      <v-spacer />
-    </v-row>
-    <v-simple-table
-      fixed-header
-    >
-      <thead>
-        <tr>
-          <th v-for="header in headers" :key="header.value" class="text-left">
-            {{ header.text }}
-          </th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr
-          v-for="(item, index) in items"
-          :key="index"
-        >
-          <td>
-            <subject-chip :subject="item.subject" />
-          </td>
-          <td v-for="header in groupHeaders" :key="header.value">
-            <timebound-course-config-raster-cell
-              :value="item[header.value]"
-              :subject="item.subject"
-              :header="header"
-              :loading="loading"
-              @addCourse="addCourse"
-              @setCourseConfigData="setCourseConfigData"
-            />
-          </td>
-        </tr>
-      </tbody>
-    </v-simple-table>
-  </div> -->
 </template>
 
 <script>
-- 
GitLab


From 61a8bd19fb2a02d139d7cbbf078f538b5f35aca1 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 17:08:49 +0200
Subject: [PATCH 31/34] Reformat

---
 .../TimeboundCourseConfigRaster.vue           | 52 +++++++++++++------
 .../TimeboundCourseConfigRasterCell.vue       |  4 +-
 aleksis/apps/lesrooster/schema/__init__.py    |  3 +-
 .../schema/timebound_course_config.py         |  2 -
 4 files changed, 38 insertions(+), 23 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index cef13fe7..6f781874 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -80,6 +80,7 @@ import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.v
 
     <template
       v-for="(groupHeader, index) in groupHeaders"
+      :key="index"
       #[tableItemSlotName(groupHeader)]="{ item, value, header }"
     >
       <timebound-course-config-raster-cell
@@ -338,28 +339,48 @@ export default {
       return Array.from(this.groupCombinationsSet);
     },
     createdCoursesReady() {
-      return !!this.createdCourses.length && this.createdCourses.every((c) => {
-        return c?.groups?.length && c.name && c.subject && c?.teachers?.length
-      });
+      return (
+        !!this.createdCourses.length &&
+        this.createdCourses.every((c) => {
+          return (
+            c?.groups?.length && c.name && c.subject && c?.teachers?.length
+          );
+        })
+      );
     },
     createdCourseConfigsReady() {
-      return !!this.createdCourseConfigs.length && this.createdCourseConfigs.every((c) => {
-        return c.course && c.validityRange && c?.teachers?.length
-      });
+      return (
+        !!this.createdCourseConfigs.length &&
+        this.createdCourseConfigs.every((c) => {
+          return c.course && c.validityRange && c?.teachers?.length;
+        })
+      );
     },
     editedCourseConfigsReady() {
-      return !!this.editedCourseConfigs.length && this.editedCourseConfigs.every((c) => {
-        return c.id && (Object.hasOwn(c, "lessonQuota") || c?.teachers?.length);
-      });
+      return (
+        !!this.editedCourseConfigs.length &&
+        this.editedCourseConfigs.every((c) => {
+          return (
+            c.id && (Object.hasOwn(c, "lessonQuota") || c?.teachers?.length)
+          );
+        })
+      );
     },
     expandedQuery() {
       return {
         ...this.$apollo.queries.subjects.options,
-        variables: JSON.parse(this.$apollo.queries.subjects.previousVariablesJson),
+        variables: JSON.parse(
+          this.$apollo.queries.subjects.previousVariablesJson,
+        ),
       };
     },
     tableLoading() {
-      return this.loading || this.$apollo.queries.subjects.loading || this.$apollo.queries.groupsForPlanning.loading || this.$apollo.queries.currentValidityRange.loading;
+      return (
+        this.loading ||
+        this.$apollo.queries.subjects.loading ||
+        this.$apollo.queries.groupsForPlanning.loading ||
+        this.$apollo.queries.currentValidityRange.loading
+      );
     },
   },
   watch: {
@@ -417,7 +438,7 @@ export default {
           this.createdCourses = [];
         }
       },
-    }
+    },
   },
   apollo: {
     currentValidityRange: {
@@ -437,10 +458,7 @@ export default {
     subjects: {
       query: subjects,
       skip() {
-        return (
-          !this.groupIDSet.size ||
-          !this.internalValidityRange
-        );
+        return !this.groupIDSet.size || !this.internalValidityRange;
       },
       variables() {
         return {
@@ -448,7 +466,7 @@ export default {
           includeChildGroups: this.includeChildGroups,
         };
       },
-      update: data => data.items,
+      update: (data) => data.items,
       result({ data }) {
         if (!data) return;
         this.items = this.generateTableItems(data.items);
diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
index 71efac96..75b7dc15 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRasterCell.vue
@@ -1,13 +1,13 @@
 <script setup>
 import PositiveSmallIntegerField from "aleksis.core/components/generic/forms/PositiveSmallIntegerField.vue";
-import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue"
+import TeacherField from "aleksis.apps.cursus/components/TeacherField.vue";
 </script>
 
 <template>
   <v-lazy
     v-model="active"
     :options="{
-      threshold: .5
+      threshold: 0.5,
     }"
     transition="fade-transition"
   >
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index dc4ea901..12ab60bb 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -217,7 +217,7 @@ class Query(graphene.ObjectType):
             info.context,
             TimeboundCourseConfig.objects.all(),
             "validity_range__school_term",
-        )        
+        )
         if not info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"):
             course_configs = get_objects_for_user(
                 info.context.user,
@@ -225,7 +225,6 @@ class Query(graphene.ObjectType):
                 course_configs,
             )
 
-
         subjects = Subject.objects.all().prefetch_related(
             Prefetch(
                 "courses",
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index f3f93d86..38c42ca6 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -1,4 +1,3 @@
-import time
 
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
@@ -10,7 +9,6 @@ from graphene_django_cud.mutations import (
     DjangoBatchDeleteMutation,
     DjangoBatchPatchMutation,
 )
-from guardian.shortcuts import get_objects_for_user
 
 from aleksis.apps.cursus.models import Course, Subject
 from aleksis.apps.cursus.schema import CourseType, SubjectType
-- 
GitLab


From bcdf89a1ba2101145a122efbe0db85d8f15cffe8 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 17:21:03 +0200
Subject: [PATCH 32/34] Remove debugging prints

---
 .../timebound_course_config/TimeboundCourseConfigRaster.vue    | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 6f781874..94c01f6a 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -244,7 +244,6 @@ export default {
       );
     },
     generateTableItems(subjects) {
-      const start = performance.now();
       const subjectsWithSortedCourses = subjects.map((subject) => {
         let { courses, ...reducedSubject } = subject;
         let groupCombinations = {};
@@ -293,8 +292,6 @@ export default {
           ...groupCombinations,
         };
       });
-      const end = performance.now();
-      console.log(`Execution time: ${end - start} ms`);
       return subjectsWithSortedCourses;
     },
   },
-- 
GitLab


From f45a9e3310b137baa04189165601056ec94663e4 Mon Sep 17 00:00:00 2001
From: Hangzhi Yu <hangzhi@protonmail.com>
Date: Thu, 10 Apr 2025 17:27:29 +0200
Subject: [PATCH 33/34] Add comments

---
 .../TimeboundCourseConfigRaster.vue           | 27 +++++++++++++++++++
 1 file changed, 27 insertions(+)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 94c01f6a..6ca10adb 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -151,13 +151,16 @@ export default {
       return "item." + header.value;
     },
     setCourseConfigData(course, subject, header, newValue) {
+      // Handles input from individual raster cells.
       if (course.newCourse) {
+        // No course for the given combination has been created yet.
         let existingCreatedCourse = this.createdCourses.find(
           (c) =>
             c.subject === subject.id &&
             JSON.stringify(c.groups) === header.value,
         );
         if (!existingCreatedCourse) {
+          // Adds new created course object with given data.
           this.createdCourses.push({
             subject: subject.id,
             groups: JSON.parse(header.value),
@@ -165,18 +168,22 @@ export default {
             ...newValue,
           });
         } else {
+          // Sets given data in existing created course object.
           for (const key in newValue) {
             this.$set(existingCreatedCourse, key, newValue[key]);
           }
         }
       } else {
+        // Course already exists.
         if (!course.lrTimeboundCourseConfigs?.length) {
+          // No TCCs exist for given course.
           let existingCreatedCourseConfig = this.createdCourseConfigs.find(
             (c) =>
               c.course === course.id &&
               c.validityRange === this.internalValidityRange?.id,
           );
           if (!existingCreatedCourseConfig) {
+            // Adds new TCC object with given data.
             this.createdCourseConfigs.push({
               course: course.id,
               validityRange: this.internalValidityRange?.id,
@@ -185,18 +192,22 @@ export default {
               ...newValue,
             });
           } else {
+            // Sets given data in existing TCC object.
             for (const key in newValue) {
               this.$set(existingCreatedCourseConfig, key, newValue[key]);
             }
           }
         } else {
+          // TCC already exists
           let courseConfigID = course.lrTimeboundCourseConfigs[0].id;
           let existingEditedCourseConfig = this.editedCourseConfigs.find(
             (c) => c.id === courseConfigID,
           );
           if (!existingEditedCourseConfig) {
+            // Adds new object representing edits made to existing TCC.
             this.editedCourseConfigs.push({ id: courseConfigID, ...newValue });
           } else {
+            // Sets given data in existing TCC edit object.
             for (const key in newValue) {
               this.$set(existingEditedCourseConfig, key, newValue[key]);
             }
@@ -205,6 +216,8 @@ export default {
       }
     },
     updateCourseConfigs(cachedSubjects, incomingCourseConfigs) {
+      // Handles incoming TCCs on partial update after mutation.
+      // Find related existing course by subject and course ID.
       incomingCourseConfigs.forEach((newCourseConfig) => {
         const subject = cachedSubjects.find(
           (s) => s.id === newCourseConfig.course.subject.id,
@@ -223,6 +236,8 @@ export default {
       return cachedSubjects;
     },
     updateCreatedCourses(cachedSubjects, incomingCourses) {
+      // Handles incoming courses on partial update after mutation.
+      // Insert course into existing data by subject ID.
       incomingCourses.forEach((newCourse) => {
         const subject = cachedSubjects.find(
           (s) => s.id === newCourse.subject.id,
@@ -237,6 +252,8 @@ export default {
       return cachedSubjects;
     },
     addCourse(subject, groups) {
+      // Handles clicks on "+" button.
+      // Adds course for given subject/groups combination and marks it as newly created.
       this.$set(
         this.items.find((i) => i.subject.id === subject),
         groups,
@@ -244,14 +261,17 @@ export default {
       );
     },
     generateTableItems(subjects) {
+      // Generates items for data table, sorted by subjects.
       const subjectsWithSortedCourses = subjects.map((subject) => {
         let { courses, ...reducedSubject } = subject;
         let groupCombinations = {};
 
         courses.forEach((course) => {
+          // Aggregates all relevant group IDs.
           const ownGroupIDs = course.groups.map((group) => group.id);
           let groupIDs;
 
+          // If child groups should be included, add them.
           if (
             this.includeChildGroups &&
             ownGroupIDs.some((groupID) => !this.groupIDSet.has(groupID))
@@ -267,6 +287,7 @@ export default {
             groupIDs = JSON.stringify(ownGroupIDs.sort());
           }
 
+          // Based on stringified aggregated groups, add group combination entry for subject.
           if (!groupCombinations[groupIDs]) {
             groupCombinations[groupIDs] = [];
             if (course.groups.length > 1) {
@@ -297,9 +318,11 @@ export default {
   },
   computed: {
     groupIDSet() {
+      // Group ID set without duplicates.
       return new Set(this.selectedGroups.map((group) => group.id));
     },
     groupCombinationHeaders() {
+      // Generates additional table headers based on unique group combinations found from existing courses.
       return this.subjectGroupCombinations.map((combination) => {
         let parsedCombination = JSON.parse(combination);
         return {
@@ -324,6 +347,7 @@ export default {
         ...header,
         width: "20vw",
       }));
+      // Adds column for subjects.
       return [
         {
           text: this.$t("lesrooster.timebound_course_config.subject"),
@@ -336,6 +360,7 @@ export default {
       return Array.from(this.groupCombinationsSet);
     },
     createdCoursesReady() {
+      // Indicates whether local created course data is ready to be used in mutation.
       return (
         !!this.createdCourses.length &&
         this.createdCourses.every((c) => {
@@ -346,6 +371,7 @@ export default {
       );
     },
     createdCourseConfigsReady() {
+      // Indicates whether local created TCCs data is ready to be used in mutation.
       return (
         !!this.createdCourseConfigs.length &&
         this.createdCourseConfigs.every((c) => {
@@ -354,6 +380,7 @@ export default {
       );
     },
     editedCourseConfigsReady() {
+      // Indicates whether local edited TCCs data is ready to be used in mutation.
       return (
         !!this.editedCourseConfigs.length &&
         this.editedCourseConfigs.every((c) => {
-- 
GitLab


From 68824efaad97325826fa8af2ba18a9eef75ee699 Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Fri, 11 Apr 2025 15:41:39 +0200
Subject: [PATCH 34/34] Fix lint and reformat

---
 .../TimeboundCourseConfigRaster.vue           |  2 +-
 aleksis/apps/lesrooster/models.py             | 10 +++++-----
 aleksis/apps/lesrooster/schema/__init__.py    |  5 +++--
 .../schema/timebound_course_bundle.py         | 20 +++++++------------
 .../schema/timebound_course_config.py         |  1 -
 5 files changed, 16 insertions(+), 22 deletions(-)

diff --git a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
index 6ca10adb..15d9ba8e 100644
--- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
+++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue
@@ -80,10 +80,10 @@ import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.v
 
     <template
       v-for="(groupHeader, index) in groupHeaders"
-      :key="index"
       #[tableItemSlotName(groupHeader)]="{ item, value, header }"
     >
       <timebound-course-config-raster-cell
+        :key="index"
         :value="value"
         :subject="item.subject"
         :header="header"
diff --git a/aleksis/apps/lesrooster/models.py b/aleksis/apps/lesrooster/models.py
index 9525137b..012b9ec3 100644
--- a/aleksis/apps/lesrooster/models.py
+++ b/aleksis/apps/lesrooster/models.py
@@ -480,8 +480,10 @@ class LessonBundle(ExtensibleModel):
         )
 
         lesson_bundle.lessons.set(
-            [Lesson.create_from_course(course, validity_range)
-             for course in course_bundle.courses.all()]
+            [
+                Lesson.create_from_course(course, validity_range)
+                for course in course_bundle.courses.all()
+            ]
         )
 
         return lesson_bundle
@@ -553,9 +555,7 @@ class Lesson(TeacherPropertiesMixin, RoomPropertiesMixin, ExtensibleModel):
     def create_from_course(cls, course: Course, validity_range: ValidityRange) -> "Lesson":
         """Create a lesson from a course backed by a validity range."""
         # Lookup the TCC for the course in the validity_range
-        tcc = TimeboundCourseConfig.objects.get(
-            course=course, validity_range=validity_range
-        )
+        tcc = TimeboundCourseConfig.objects.get(course=course, validity_range=validity_range)
         lesson = cls.objects.create(
             course=course,
             subject=course.subject,
diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py
index 74942fa8..f67ad826 100644
--- a/aleksis/apps/lesrooster/schema/__init__.py
+++ b/aleksis/apps/lesrooster/schema/__init__.py
@@ -275,8 +275,9 @@ class Query(graphene.ObjectType):
             return []
 
         return graphene_django_optimizer.query(
-            CourseBundle.objects
-            .filter(Q(courses__groups__id=group) | Q(courses__groups__parent_groups__id=group))
+            CourseBundle.objects.filter(
+                Q(courses__groups__id=group) | Q(courses__groups__parent_groups__id=group)
+            )
             .distinct()
             .annotate(validity_range_id=Value(validity_range)),
             info,
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_bundle.py b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
index 500be9e8..918e44eb 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py
@@ -15,22 +15,16 @@ class TimeboundCourseType(BaseCourseType):
 
     def resolve_lesson_quota(root, info):
         """Resolve lesson_quota from timebound_course_config"""
-        return (
-            TimeboundCourseConfig.objects
-            .get(
-                course=root,
-                validity_range__id=root.validity_range_id,
-            )
-            .lesson_quota
-        )
+        return TimeboundCourseConfig.objects.get(
+            course=root,
+            validity_range__id=root.validity_range_id,
+        ).lesson_quota
 
     def resolve_teachers(root, info):
         """Resolve teachers from timebound_course_config"""
-        return (
-            TimeboundCourseConfig.objects
-            .get(course=root, validity_range__id=root.validity_range_id)
-            .teachers.all()
-        )
+        return TimeboundCourseConfig.objects.get(
+            course=root, validity_range__id=root.validity_range_id
+        ).teachers.all()
 
 
 class TimeboundCourseBundleType(BaseCourseBundleType):
diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py
index 38c42ca6..b8a38461 100644
--- a/aleksis/apps/lesrooster/schema/timebound_course_config.py
+++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py
@@ -1,4 +1,3 @@
-
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
-- 
GitLab