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 8ef85cd87d535934e78950933dad314402950299..15d9ba8ec30ebf39275efc0563098b1363e5fe15 100644 --- a/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue +++ b/aleksis/apps/lesrooster/frontend/components/timebound_course_config/TimeboundCourseConfigRaster.vue @@ -1,188 +1,98 @@ <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"; +import TimeboundCourseConfigRasterCell from "./TimeboundCourseConfigRasterCell.vue"; </script> <template> - <div> - <v-data-table - disable-sort - disable-filtering - disable-pagination - hide-default-footer - :headers="headers" - :items="tableItems" - > - <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 - label="Select Validity Range" - 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-spacer /> + <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="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" - class="d-flex justify-space-between flex-wrap align-center" - > - <save-button - :disabled=" - !editedCourseConfigs.length && - !createdCourseConfigs.length && - !createdCourses.length - " - @click="save" - /> - </v-col> - </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 }" - > - <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')" - @input=" - (event) => - setCourseConfigData(course, item.subject, header, { - lessonQuota: event, - }) - " - /> - </v-col> - <v-col cols="6"> - <v-autocomplete - counter - 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')" - :value=" - getCurrentCourseConfig(course) - ? getCurrentCourseConfig(course).teachers - : course.teachers - " - @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> - <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> + <template + v-for="(groupHeader, index) in groupHeaders" + #[tableItemSlotName(groupHeader)]="{ item, value, header }" + > + <timebound-course-config-raster-cell + :key="index" + :value="value" + :subject="item.subject" + :header="header" + :loading="loading" + @addCourse="addCourse" + @setCourseConfigData="setCourseConfigData" + /> + </template> + </v-data-table> </template> <script> @@ -190,21 +100,18 @@ import { subjects, createTimeboundCourseConfigs, updateTimeboundCourseConfigs, - createCoursesForSchoolTerm, + createCoursesForValidityRange, } from "./timeboundCourseConfig.graphql"; import { currentValidityRange as gqlCurrentValidityRange } from "../validity_range/validityRange.graphql"; -import { - gqlGroupsForPlanning, - gqlGroups, - gqlTeachers, -} from "../helper.graphql"; +import { gqlGroupsForPlanning } from "../helper.graphql"; -import { createCourses } from "aleksis.apps.cursus/components/course.graphql"; +import mutateMixin from "aleksis.core/mixins/mutateMixin.js"; export default { name: "TimeboungCourseConfigRaster", + mixins: [mutateMixin], data() { return { i18nKey: "lesrooster.timebound_course_config", @@ -230,40 +137,30 @@ export default { subjects: [], editedCourseConfigs: [], createdCourseConfigs: [], - newCourses: [], createdCourses: [], currentCourse: null, currentSubject: null, - loading: false, + includeChildGroups: true, + selectedGroupHeaders: [], + items: [], + groupCombinationsSet: new Set(), }; }, methods: { tableItemSlotName(header) { return "item." + header.value; }, - 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; - } - } else { - return null; - } - }, 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), @@ -271,20 +168,22 @@ export default { ...newValue, }); } else { - Object.assign(existingCreatedCourse, newValue); + // Sets given data in existing created course object. + for (const key in newValue) { + this.$set(existingCreatedCourse, key, newValue[key]); + } } } else { - if ( - !course.lrTimeboundCourseConfigs?.filter( - (c) => c.validityRange.id === this.internalValidityRange?.id, - ).length - ) { + // 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, @@ -293,136 +192,162 @@ export default { ...newValue, }); } else { - Object.assign(existingCreatedCourseConfig, newValue); + // 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 { - Object.assign(existingEditedCourseConfig, newValue); + // Sets given data in existing TCC edit object. + for (const key in newValue) { + this.$set(existingEditedCourseConfig, key, newValue[key]); + } } } } }, - save() { - this.loading = true; - - for (let mutationCombination of [ - { - data: this.editedCourseConfigs, - mutation: updateTimeboundCourseConfigs, - }, - { - data: this.createdCourseConfigs, - mutation: createTimeboundCourseConfigs, - }, - { - data: this.createdCourses, - mutation: createCoursesForSchoolTerm, - }, - ]) { - if (mutationCombination.data.length) { - this.$apollo - .mutate({ - mutation: mutationCombination.mutation, - variables: { - input: mutationCombination.data, - }, - }) - .catch(() => {}); // FIXME Error Handling + 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, + ); + if (!subject) { + return; } - } - this.editedCourseConfigs = []; - this.createdCourseConfigs = []; - this.createdCourses = []; - this.$apollo.queries.subjects.refetch(); - this.loading = false; + const course = subject.courses.find( + (c) => c.id === newCourseConfig.course.id, + ); + + course.lrTimeboundCourseConfigs = [newCourseConfig]; + }); + + 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), - ), - ]; + 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, + ); + if (!subject) { + return; + } + + subject.courses.push(newCourse); + }); + + return cachedSubjects; }, addCourse(subject, groups) { - let courseSubjectGroup = this.newCourses.find( - (courseSubject) => courseSubject.subject === subject, + // 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, + [{ 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) { + // 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)) + ) { + groupIDs = JSON.stringify( + course.groups + .flatMap((group) => + group.parentGroups.map((parentGroup) => parentGroup.id), + ) + .sort(), + ); + } else { + 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) { + 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, + }; + }); + return subjectsWithSortedCourses; }, }, computed: { - groupIDList() { - return this.selectedGroups.map((group) => group.id); + groupIDSet() { + // Group ID set without duplicates. + return new Set(this.selectedGroups.map((group) => group.id)); }, - subjectGroupCombinations() { - return [].concat.apply( - [], - this.items.map((subject) => Object.keys(subject.groupCombinations)), - ); + 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 { + 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: "20vw", })); + // Adds column for subjects. return [ { text: this.$t("lesrooster.timebound_course_config.subject"), @@ -431,52 +356,112 @@ 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(), + subjectGroupCombinations() { + 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) => { + return ( + c?.groups?.length && c.name && c.subject && c?.teachers?.length + ); + }) + ); + }, + createdCourseConfigsReady() { + // Indicates whether local created TCCs data is ready to be used in mutation. + return ( + !!this.createdCourseConfigs.length && + this.createdCourseConfigs.every((c) => { + return c.course && c.validityRange && c?.teachers?.length; + }) + ); + }, + editedCourseConfigsReady() { + // Indicates whether local edited TCCs data is ready to be used in mutation. + 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, + ), + }; + }, + tableLoading() { + return ( + this.loading || + this.$apollo.queries.subjects.loading || + this.$apollo.queries.groupsForPlanning.loading || + this.$apollo.queries.currentValidityRange.loading + ); + }, + }, + watch: { + selectedGroups(newValue) { + this.selectedGroupHeaders = newValue.map((group) => ({ + text: group.shortName, + value: JSON.stringify([group.id]), + })); + }, + editedCourseConfigs: { + deep: true, + handler(newValue) { + if (this.editedCourseConfigsReady) { + this.mutate( + updateTimeboundCourseConfigs, + { + input: newValue, + }, + this.updateCourseConfigs, ); - 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, - }, - }; + this.editedCourseConfigs = []; + } + }, + }, + createdCourseConfigs: { + deep: true, + handler(newValue) { + if (this.createdCourseConfigsReady) { + this.mutate( + createTimeboundCourseConfigs, + { + input: newValue, + }, + this.updateCourseConfigs, + ); - return subject; - }); + this.createdCourseConfigs = []; + } + }, }, - 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, - }; - }); + createdCourses: { + deep: true, + handler(newValue) { + if (this.createdCoursesReady) { + this.mutate( + createCoursesForValidityRange, + { + input: newValue, + validityRange: this.internalValidityRange.id, + }, + this.updateCreatedCourses, + ); + + this.createdCourses = []; + } + }, }, }, apollo: { @@ -487,30 +472,29 @@ 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; }, variables() { return { - groups: this.groupIDList, + groups: Array.from(this.groupIDSet), + includeChildGroups: this.includeChildGroups, }; }, - }, - persons: { - query: gqlTeachers, + 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 new file mode 100644 index 0000000000000000000000000000000000000000..75b7dc153530d62e61879c61995a7bd52da52a47 --- /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: 0.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> 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..15d3954d60538d2fe39acdd2453b2fd9c58bc845 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,17 @@ mutation updateTimeboundCourseConfigs( } } -query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) { - subjects: lesroosterExtendedSubjects( +query subjects( + $orderBy: [String] + $filters: JSONString + $groups: [ID] + $includeChildGroups: Boolean! +) { + items: lesroosterExtendedSubjects( orderBy: $orderBy filters: $filters groups: $groups + includeChildGroups: $includeChildGroups ) { ...subjectFields courses { @@ -109,24 +120,19 @@ query subjects($orderBy: [String], $filters: JSONString, $groups: [ID]) { } } -mutation createCoursesForSchoolTerm($input: [CourseInputType]!) { - createCoursesForSchoolTerm(input: $input) { +mutation createCoursesForValidityRange( + $input: [CourseInputType]! + $validityRange: ID! +) { + 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/frontend/components/validity_range/ValidityRangeField.vue b/aleksis/apps/lesrooster/frontend/components/validity_range/ValidityRangeField.vue index 5be88f33b042d90d7cb241819a7df2c69f0e8936..8ff63e95d635080c13e135a1e484366cb3037a3a 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="{}" diff --git a/aleksis/apps/lesrooster/frontend/messages/en.json b/aleksis/apps/lesrooster/frontend/messages/en.json index 9a73841fd9d09a333e582359d3f4f60dfb8b5b18..72325fa572621c2e33dc878d6b4d10cd7025a154 100644 --- a/aleksis/apps/lesrooster/frontend/messages/en.json +++ b/aleksis/apps/lesrooster/frontend/messages/en.json @@ -87,7 +87,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/models.py b/aleksis/apps/lesrooster/models.py index 9525137be45549e57dabccfef74b6a9600cae5ce..012b9ec38afe13421a651c7ee1fe337fe0e27ec4 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 4445ee51d9dcc347d21801b059d621da29432768..f67ad826acaa4fab36c41b7b1be0cb8de700f4c1 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, @@ -126,7 +126,9 @@ 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)) @@ -203,15 +205,39 @@ class Query(graphene.ObjectType): return graphene_django_optimizer.query(qs, info) @staticmethod - def resolve_lesrooster_extended_subjects(root, info, 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) + ) + else: + courses = Course.objects.filter(groups__in=groups) + + course_configs = filter_active_school_term( + 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( "courses", queryset=get_objects_for_user( - info.context.user, "cursus.view_course", Course.objects.all() - ).filter(groups__in=groups), + info.context.user, + "cursus.view_course", + courses.prefetch_related( + Prefetch("lr_timebound_course_configs", queryset=course_configs) + ), + ), ) ) + if not info.context.user.has_perm("lesrooster.view_subject_rule"): return graphene_django_optimizer.query( get_objects_for_user(info.context.user, "cursus.view_subject", subjects), info @@ -249,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, @@ -427,4 +454,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_bundle.py b/aleksis/apps/lesrooster/schema/timebound_course_bundle.py index 500be9e8d91124ecc6cca0581fa8d60feeb13191..918e44eb2b4ad0f926720ceb106ddbc7ed4eb077 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 ab647bca271aa3689c349dcc6dcfe60c431dd26f..b8a38461f433e8ab45baf332f7f4d679e1532b43 100644 --- a/aleksis/apps/lesrooster/schema/timebound_course_config.py +++ b/aleksis/apps/lesrooster/schema/timebound_course_config.py @@ -8,7 +8,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 @@ -23,7 +22,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": [""]} @@ -67,13 +66,9 @@ 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() + if info.context.user.has_perm("lesrooster.view_timeboundcourseconfig_rule"): + return root.lr_timebound_course_configs.all() + return [] class LesroosterExtendedSubjectType(SubjectType): @@ -113,26 +108,36 @@ 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) + courses = graphene.List(LesroosterExtendedCourseType) @classmethod - def create(cls, info, course_input): + 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) 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, ) 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) @@ -163,6 +168,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)