diff --git a/aleksis/apps/chronos/frontend/components/NoTimetableCard.vue b/aleksis/apps/chronos/frontend/components/NoTimetableCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..12c21e656097e1a848b9d139e228b34e6a9d356f --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/NoTimetableCard.vue @@ -0,0 +1,34 @@ +<script> +export default { + name: "NoTimetableCard", + props: { + titleKey: { + type: String, + required: false, + default: "chronos.timetable.no_timetable_selected.title", + }, + descriptionKey: { + type: String, + required: false, + default: "chronos.timetable.no_timetable_selected.description", + }, + }, +}; +</script> + +<template> + <v-card + class="full-height d-flex align-center justify-center py-10" + v-bind="$attrs" + > + <div class="text-center"> + <v-icon color="secondary" size="60" class="mb-4"> mdi-grid-off </v-icon> + <div class="text-h5 grey--text text--darken-2 mb-2"> + {{ $t(titleKey) }} + </div> + <div class="text-body-2 grey--text text--darken-2"> + {{ $t(descriptionKey) }} + </div> + </div> + </v-card> +</template> diff --git a/aleksis/apps/chronos/frontend/components/Timetable.vue b/aleksis/apps/chronos/frontend/components/Timetable.vue new file mode 100644 index 0000000000000000000000000000000000000000..3a7471f6210136eeb59d4aa81f8fa061c0c3c56f --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/Timetable.vue @@ -0,0 +1,218 @@ +<script> +import { gqlAvailableTimetables } from "./timetables.graphql"; +import NoTimetableCard from "./NoTimetableCard.vue"; + +export default { + name: "Timetable", + components: { NoTimetableCard }, + apollo: { + availableTimetables: { + query: gqlAvailableTimetables, + result(data) { + console.log( + data.data.availableTimetables.map((a) => { + return a.id + a.name; + }) + ); + }, + }, + }, + data() { + return { + availableTimetables: [], + selected: null, + selectedFull: null, + search: "", + selectedTypes: ["GROUP", "TEACHER", "ROOM"], + types: { + GROUP: { + name: "Groups", + id: "GROUP", + icon: "mdi-account-group-outline", + }, + TEACHER: { + name: "Teachers", + id: "TEACHER", + icon: "mdi-account-outline", + }, + ROOM: { name: "Rooms", id: "ROOM", icon: "mdi-door" }, + }, + }; + }, + watch: { + selected(selected) { + if (selected == null) { + this.selectedFull = null; + } + }, + selectedFull(selectedFull) { + // Align navigation with currently selected timetable + if (!selectedFull) { + this.$router.push({ name: "chronos.timetable" }); + } else if ( + selectedFull.objId !== this.$route.params.id || + selectedFull.type !== this.$route.params.type + ) { + this.$router.push({ + name: "chronos.timetableWithId", + params: { + type: selectedFull.type.toLowerCase(), + id: selectedFull.objId, + }, + }); + } + }, + }, + methods: { + findNextTimetable(offset = 1) { + const currentIndex = this.availableTimetablesIds.indexOf( + this.selectedFull.id + ); + const newIndex = currentIndex + offset; + if (newIndex < 0 || newIndex >= this.availableTimetablesIds.length) { + return null; + } + return this.availableTimetables[newIndex]; + }, + selectTimetable(timetable) { + this.selected = timetable.id; + this.selectedFull = timetable; + }, + }, + computed: { + selectedTypesFull() { + return this.selectedTypes.map((type) => { + return this.types[type]; + }); + }, + availableTimetablesFiltered() { + // Filter timetables by selected types + return this.availableTimetables.filter((timetable) => { + return this.selectedTypes.indexOf(timetable.type) !== -1; + }); + }, + availableTimetablesIds() { + return this.availableTimetables.map((timetable) => timetable.id); + }, + prevTimetable() { + return this.findNextTimetable(-1); + }, + nextTimetable() { + return this.findNextTimetable(1); + }, + }, +}; +</script> + +<template> + <div> + <v-row> + <v-col cols="3"> + <v-card> + <v-card-text class="mb-0"> + <!-- Search field for timetables --> + <v-text-field + search + filled + rounded + clearable + autofocus + v-model="search" + :placeholder="$t('chronos.timetable.search')" + prepend-inner-icon="mdi-magnify" + hide-details="auto" + class="mb-2" + /> + + <!-- Filter by timetable types --> + <v-btn-toggle + v-model="selectedTypes" + dense + block + multiple + class="d-flex" + > + <v-btn + v-for="type in types" + :key="type.id" + class="flex-grow-1" + :value="type.id" + > + {{ type.name }} + </v-btn> + </v-btn-toggle> + </v-card-text> + + <!-- Select of available timetables --> + <v-data-iterator + :items="availableTimetablesFiltered" + item-key="id" + :search="search" + single-expand + > + <template #default="{ items, isExpanded, expand }"> + <v-list> + <v-list-item-group v-model="selected"> + <v-list-item + v-for="item in items" + @click="selectedFull = item" + :value="item.id" + :key="item.id" + > + <v-list-item-icon color="primary"> + <v-icon v-if="item.type in types" color="secondary"> + {{ types[item.type].icon }} + </v-icon> + <v-icon v-else color="secondary">mdi-grid</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title>{{ item.name }}</v-list-item-title> + </v-list-item-content> + <v-list-item-action> + <v-icon>mdi-chevron-right</v-icon> + </v-list-item-action> + </v-list-item> + </v-list-item-group> + </v-list> + </template> + </v-data-iterator> + </v-card> + </v-col> + <v-col cols="9" class="full-height"> + <!-- No timetable card--> + <no-timetable-card v-if="selectedFull == null" /> + + <!-- Calendar card--> + <v-card v-else> + <div class="d-flex"> + <v-card-title> + {{ selectedFull.name }} + </v-card-title> + <v-spacer /> + <div class="pa-2 mt-1"> + <v-btn + icon + :disabled="!prevTimetable" + @click="selectTimetable(prevTimetable)" + :title="$t('chronos.timetable.prev')" + > + <v-icon>mdi-chevron-left</v-icon> + </v-btn> + <v-chip label color="secondary" outlined class="mx-1">{{ + selectedFull.shortName + }}</v-chip> + <v-btn + icon + :disabled="!nextTimetable" + @click="selectTimetable(nextTimetable)" + :title="$t('chronos.timetable.next')" + > + <v-icon>mdi-chevron-right</v-icon> + </v-btn> + </div> + </div> + </v-card> + </v-col> + </v-row> + </div> +</template> diff --git a/aleksis/apps/chronos/frontend/components/timetables.graphql b/aleksis/apps/chronos/frontend/components/timetables.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5bbe46f734f1981ad1c2648d50a8a6e8d7bd04fc --- /dev/null +++ b/aleksis/apps/chronos/frontend/components/timetables.graphql @@ -0,0 +1,9 @@ +query gqlAvailableTimetables { + availableTimetables { + id + objId + type + name + shortName + } +} diff --git a/aleksis/apps/chronos/frontend/index.js b/aleksis/apps/chronos/frontend/index.js index 501071992b10bfad99480b80d2810628d19b48aa..c6492797fe70c3e789fb1f937c7035ad7b3d1da7 100644 --- a/aleksis/apps/chronos/frontend/index.js +++ b/aleksis/apps/chronos/frontend/index.js @@ -1,4 +1,5 @@ import { hasPersonValidator } from "aleksis.core/routeValidators"; +import Timetable from "./components/Timetable.vue"; export default { meta: { @@ -11,6 +12,22 @@ export default { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, }, children: [ + { + path: "timetable/", + component: Timetable, + name: "chronos.timetable", + meta: { + inMenu: true, + titleKey: "chronos.timetable.menu_title", + icon: "mdi-grid", + permission: "chronos.view_timetables_rule", + }, + }, + { + path: "timetable/:type/:id/", + component: Timetable, + name: "chronos.timetableWithId", + }, { path: "", component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), diff --git a/aleksis/apps/chronos/frontend/messages/en.json b/aleksis/apps/chronos/frontend/messages/en.json index fb73dca543dfd16a3f9371e3f813fc3d8e4cbd58..4128f0b933a0d07e698204c678da08ca7a9177d3 100644 --- a/aleksis/apps/chronos/frontend/messages/en.json +++ b/aleksis/apps/chronos/frontend/messages/en.json @@ -3,7 +3,14 @@ "menu_title": "Timetables", "timetable": { "menu_title_all": "All timetables", - "menu_title_my": "My timetable" + "menu_title_my": "My timetable", + "no_timetable_selected": { + "title": "No Timetable Selected", + "description": "Select a timetable on the left side to show it in this place" + }, + "search": "Search Timetables", + "prev": "Previous Timetable", + "next": "Next Timetable" }, "lessons": { "menu_title_daily": "Daily lessons" diff --git a/aleksis/apps/chronos/schema/__init__.py b/aleksis/apps/chronos/schema/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e5c7d4e85bd83b35b0ba4873c36b7f6b0ad2aa8f --- /dev/null +++ b/aleksis/apps/chronos/schema/__init__.py @@ -0,0 +1,95 @@ +import graphene +from graphene_django import DjangoObjectType + +from aleksis.core.models import Group, Person, Room + +from ..util.chronos_helpers import get_classes, get_rooms, get_teachers + + +class TimetablePersonType(DjangoObjectType): + class Meta: + model = Person + fields = ("id", "first_name", "last_name", "short_name") + skip_registry = True + + +class TimetableGroupType(DjangoObjectType): + class Meta: + model = Group + fields = ("id", "name", "short_name") + skip_registry = True + + +class TimetableRoomType(DjangoObjectType): + class Meta: + model = Room + fields = ("id", "name", "short_name") + skip_registry = True + + +class TimetableType(graphene.Enum): + TEACHER = "teacher" + GROUP = "group" + ROOM = "room" + + +class TimetableObjectType(graphene.ObjectType): + id = graphene.String() # noqa + obj_id = graphene.String() + name = graphene.String() + short_name = graphene.String() + type = graphene.Field(TimetableType) # noqa + + def resolve_obj_id(root, info, **kwargs): + return root.id + + def resolve_id(root, info, **kwargs): + return f"{root.type.value}-{root.id}" + + +class Query(graphene.ObjectType): + timetable_teachers = graphene.List(TimetablePersonType) + timetable_groups = graphene.List(TimetableGroupType) + timetable_rooms = graphene.List(TimetableRoomType) + available_timetables = graphene.List(TimetableObjectType) + + def resolve_timetable_teachers(self, info, **kwargs): + return get_teachers(info.context.user) + + def resolve_timetable_groups(self, info, **kwargs): + return get_classes(info.context.user) + + def resolve_timetable_rooms(self, info, **kwargs): + return get_rooms(info.context.user) + + def resolve_available_timetables(self, info, **kwargs): + all_timetables = [] + for group in get_classes(info.context.user): + all_timetables.append( + TimetableObjectType( + id=group.id, + name=group.name, + short_name=group.short_name, + type=TimetableType.GROUP, + ) + ) + + for teacher in get_teachers(info.context.user): + print(teacher.full_name) + all_timetables.append( + TimetableObjectType( + id=teacher.id, + name=teacher.full_name, + short_name=teacher.short_name, + type=TimetableType.TEACHER, + ) + ) + + for room in get_rooms(info.context.user): + all_timetables.append( + TimetableObjectType( + id=room.id, name=room.name, short_name=room.short_name, type=TimetableType.ROOM + ) + ) + + return all_timetables diff --git a/aleksis/apps/chronos/util/chronos_helpers.py b/aleksis/apps/chronos/util/chronos_helpers.py index 17d98c54a88e28c9be5ca81d8561924d4155c83a..5664c0b97e13e68ab18120a2cadfd39396eaa8a4 100644 --- a/aleksis/apps/chronos/util/chronos_helpers.py +++ b/aleksis/apps/chronos/util/chronos_helpers.py @@ -9,7 +9,7 @@ from django.utils import timezone from guardian.core import ObjectPermissionChecker -from aleksis.core.models import Announcement, Group, Person, Room, SchoolTerm +from aleksis.core.models import Announcement, Group, Person, Room from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.predicates import check_global_permission @@ -74,10 +74,8 @@ def get_teachers(user: "User"): """Get the teachers whose timetables are allowed to be seen by current user.""" checker = ObjectPermissionChecker(user) - school_term = SchoolTerm.current - school_term_q = Q(lessons_as_teacher__validity__school_term=school_term) if school_term else Q() teachers = ( - Person.objects.annotate(lessons_count=Count("lessons_as_teacher", filter=school_term_q)) + Person.objects.annotate(lessons_count=Count("lesson_events_as_teacher")) .filter(lessons_count__gt=0) .order_by("short_name", "last_name") ) @@ -93,6 +91,8 @@ def get_teachers(user: "User"): teachers = teachers.filter(Q(pk=user.person.pk) | Q(pk__in=wanted_teachers)) + teachers = teachers.distinct() + return teachers @@ -103,8 +103,8 @@ def get_classes(user: "User"): classes = ( Group.objects.for_current_school_term_or_all() .annotate( - lessons_count=Count("lessons"), - child_lessons_count=Count("child_groups__lessons"), + lessons_count=Count("lesson_events"), + child_lessons_count=Count("child_groups__lesson_events"), ) .filter( Q(lessons_count__gt=0, parent_groups=None) @@ -128,6 +128,8 @@ def get_classes(user: "User"): if user.person.primary_group: classes = classes.filter(Q(pk=user.person.primary_group.pk)) + classes = classes.distinct() + return classes @@ -135,13 +137,8 @@ def get_rooms(user: "User"): """Get the rooms whose timetables are allowed to be seen by current user.""" checker = ObjectPermissionChecker(user) - school_term = SchoolTerm.current - school_term_q = ( - Q(lesson_periods__lesson__validity__school_term=school_term) if school_term else Q() - ) - rooms = ( - Room.objects.annotate(lessons_count=Count("lesson_periods", filter=school_term_q)) + Room.objects.annotate(lessons_count=Count("lesson_events")) .filter(lessons_count__gt=0) .order_by("short_name", "name") ) @@ -157,6 +154,8 @@ def get_rooms(user: "User"): rooms = rooms.filter(Q(pk__in=wanted_rooms)) + rooms = rooms.distinct() + return rooms