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 c6d572a1ba6b0d00ae11f088d8b135eb0be9053e..32fa24b2d472c4e6021c63019f41fc1f206e0070 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,8 @@ 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> <v-row> @@ -33,11 +34,36 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; item-value="id" return-object :disabled="$apollo.queries.groupsForPlanning.loading" - :label="$t('lesrooster.timebound_course_config.groups')" - :loading="$apollo.queries.groupsForPlanning.loading" + :label=" + $t('lesrooster.timebound_course_config.filters.groups.label') + " + :loading="$apollo.queries.grouptemplatesForPlanning.loading" v-model="selectedGroups" class="mr-4" - /> + > + <template #selection="{ item, index }"> + <div v-if="selectedGroups.length === groupsForPlanning.length"> + <span v-if="index === 0" class="grey--text"> + {{ + $t( + "lesrooster.timebound_course_config.filters.groups.all_groups", + ) + }} + </span> + </div> + <div v-else> + <span v-if="index === 0">{{ item.shortName }}</span> + <span v-if="index === 1" class="grey--text text-caption"> + {{ + $t( + "lesrooster.timebound_course_config.filters.groups.other_groups", + { groupCount: selectedGroups.length - 1 }, + ) + }} + </span> + </div> + </template> + </v-autocomplete> </v-col> <v-col @@ -48,26 +74,34 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; <validity-range-field outlined filled - label="Select Validity Range" + :label=" + $t('lesrooster.timebound_course_config.filters.validity_range') + " hide-details v-model="internalValidityRange" :loading="$apollo.queries.currentValidityRange.loading" /> </v-col> - <v-spacer /> - <v-col - cols="8" - lg="3" + cols="6" + lg="2" class="d-flex justify-space-between flex-wrap align-center" > - <secondary-action-button - i18n-key="actions.copy_last_configuration" - block - class="mr-4" - /> + <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-col cols="4" lg="1" @@ -79,6 +113,7 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; !createdCourseConfigs.length && !createdCourses.length " + :loading="loading" @click="save" /> </v-col> @@ -100,12 +135,13 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; v-for="(course, index) in value" :key="index" no-gutters - class="mt-2" + class="my-1" > - <v-col cols="6"> + <v-col cols="4"> <positive-small-integer-field dense - filled + outlined + hide-details class="mx-1" :disabled="loading" :value=" @@ -122,14 +158,16 @@ import SubjectChip from "aleksis.apps.cursus/components/SubjectChip.vue"; " /> </v-col> - <v-col cols="6"> + <v-col cols="8"> <v-autocomplete - counter dense - filled + outlined multiple + chips + small-chips + hide-details :items="getTeacherList(item.subject.teachers)" - item-text="fullName" + item-text="shortName" item-value="id" class="mx-1" :disabled="loading" @@ -230,11 +268,14 @@ export default { subjects: [], editedCourseConfigs: [], createdCourseConfigs: [], - newCourses: [], createdCourses: [], currentCourse: null, currentSubject: null, loading: false, + includeChildGroups: true, + selectedGroupHeaders: [], + items: [], + groupCombinationsSet: new Set(), }; }, methods: { @@ -243,15 +284,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; } @@ -267,7 +300,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, }); @@ -275,11 +307,7 @@ export default { Object.assign(existingCreatedCourse, newValue); } } else { - if ( - !course.lrTimeboundCourseConfigs?.filter( - (c) => c.validityRange.id === this.internalValidityRange?.id, - ).length - ) { + if (!course.lrTimeboundCourseConfigs?.length) { let existingCreatedCourseConfig = this.createdCourseConfigs.find( (c) => c.course === course.id && @@ -363,66 +391,91 @@ export default { ]; }, addCourse(subject, groups) { - let courseSubjectGroup = this.newCourses.find( - (courseSubject) => courseSubject.subject === subject, + this.$set( + this.items.find((i) => i.subject.id === subject), + groups, + [{ teachers: [], newCourse: true }], ); - if (courseSubjectGroup) { - if (courseSubjectGroup.groupCombinations) { - this.$set(courseSubjectGroup.groupCombinations, 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)); }, - subjectGroupCombinations() { - return [].concat.apply( - [], - this.items.map((subject) => Object.keys(subject.groupCombinations)), - ); + 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) => ({ ...header, - width: `${Math.max(95 / this.groupHeaders.length, 15)}vw`, + width: "15vw", })); return [ { @@ -432,52 +485,16 @@ export default { }, ].concat(groupHeadersWithWidth); }, - items() { - return this.subjects.map((subject) => { - let groupCombinations = {}; - - subject.courses.forEach((course) => { - let groupIds = JSON.stringify( - course.groups.map((group) => group.id).sort(), - ); - - if (!groupCombinations[groupIds]) { - groupCombinations[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; - }); + subjectGroupCombinations() { + return Array.from(this.groupCombinationsSet); }, - 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, - }; - }); + }, + watch: { + selectedGroups(newValue) { + this.selectedGroupHeaders = newValue.map((group) => ({ + text: group.shortName, + value: JSON.stringify([group.id]), + })); }, }, apollo: { @@ -488,27 +505,33 @@ export default { this.internalValidityRange = data.currentValidityRange; }, }, - groups: { - query: gqlGroups, - }, groupsForPlanning: { 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 || + !this.persons.length + ); }, variables() { return { - groups: this.groupIDList, + groups: Array.from(this.groupIDSet), + includeChildGroups: this.includeChildGroups, + validityRange: this.internalValidityRange.id, }; }, + result({ data }) { + if (!data) return; + this.items = this.generateTableItems(data.subjects); + }, }, persons: { query: gqlTeachers, 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 c70792897146cc84e6afb74ac070d92ab9d0dfd7..b5a2887b3ba595aaeb3b4a8027f63e9afc8ef4bb 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,19 @@ mutation updateTimeboundCourseConfigs( } } -query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) { +query subjects( + $orderBy: [String] + $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/frontend/messages/de.json b/aleksis/apps/lesrooster/frontend/messages/de.json index da44980d0e3f51e4ceccff6fb30c04ee4cb04835..4ffab852e92641de74f11567ccc0100d752e84e6 100644 --- a/aleksis/apps/lesrooster/frontend/messages/de.json +++ b/aleksis/apps/lesrooster/frontend/messages/de.json @@ -23,6 +23,31 @@ "title": "Pause", "title_plural": "Pausen" }, + "timebound_course_config": { + "crud_table_menu_title": "Kurskonfigurationen", + "raster_menu_title": "Kurse planen", + "title": "Kurskonfiguration", + "title_plural": "Kurskonfigurationen", + "lesson_quota": "Stundenpensum", + "course": "Kurs", + "teachers": "Lehrkräfte", + "teachers_for": "Lehrkräfte für", + "subject_teachers": "Fachlehrkräfte", + "all_teachers": "Alle Lehrkräfte", + "no_course_selected": "Kein Kurs ausgewählt", + "create_timebound_course_config": "Kurskonfiguration erstellen", + "subject": "Fach", + "filters": { + "include_child_groups": "Kurse von Kindgruppen einbeziehen", + "validity_range": "Gültigkeitszeitraum", + "groups": { + "label": "Gruppen", + "other_groups": " (+{groupCount} andere)", + "all_groups": "Alle ausgewählt" + } + } + }, + }, "lesson_raster": { "menu_title": "Stundenraster" }, diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 9a73841fd9d09a333e582359d3f4f60dfb8b5b18..5b6469f99911ebae10e1b9feb2625891fbcd7bac 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -78,16 +78,24 @@ "raster_menu_title": "Plan courses", "title": "Timebound course config", "title_plural": "Timebound course configs", - "lesson_quota": "Scheduled lesson quota", + "lesson_quota": "Lessons", "course": "Course", - "groups": "Groups", "teachers": "Teachers", "teachers_for": "Teachers for", "subject_teachers": "Teachers for this subject", "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", + "validity_range": "Validity range", + "groups": { + "label": "Groups", + "other_groups": " (+{groupCount} others)", + "all_groups": "All selected" + } + } }, "lesson_raster": { "menu_title": "Lesson Raster" diff --git a/aleksis/apps/lesrooster/schema/__init__.py b/aleksis/apps/lesrooster/schema/__init__.py index e8bd683f26defa9e0e20c491e0b6a7040decf7ad..51713021712c572feb2d47ee71e8fee0a5e6d013 100644 --- a/aleksis/apps/lesrooster/schema/__init__.py +++ b/aleksis/apps/lesrooster/schema/__init__.py @@ -111,7 +111,10 @@ 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), + validity_range=graphene.ID(required=True), ) groups_by_time_grid = graphene.List(GroupType, time_grid=graphene.ID(required=True)) @@ -154,18 +157,29 @@ class Query(graphene.ObjectType): return Supervision.objects.all() @staticmethod - def resolve_lesrooster_extended_subjects(root, info, groups): - subjects = Subject.objects.all().prefetch_related( + 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) + 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() + ) + + return subjects.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"): - return get_objects_for_user(info.context.user, "cursus.view_subject", subjects) - return subjects @staticmethod def resolve_current_validity_range(root, info): diff --git a/aleksis/apps/lesrooster/schema/timebound_course_config.py b/aleksis/apps/lesrooster/schema/timebound_course_config.py index 1d1e07a5e05e6ea2d139d3eb8def7b248d6700e3..87426363328ce1c3d8b981a5f20649df32d64094 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, )