diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js index 3e32230a6637f2748797bbe91afe2a16ad4add64..4cccdd2978ed417ea1dd62f1da9ee3770013b733 100644 --- a/aleksis/core/frontend/app/vuetify.js +++ b/aleksis/core/frontend/app/vuetify.js @@ -32,6 +32,7 @@ const vuetifyOpts = { home: "mdi-home-outline", groupType: "mdi-shape-outline", print: "mdi-printer-outline", + schoolTerm: "mdi-calendar-range-outline", }, }, }; diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index d4496e09f1e3593974de598ca5f8bed712fefd07..d522d33fc65106b853a07e4e8285b632858fc84c 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -56,6 +56,7 @@ v-if="whoAmI && whoAmI.isAuthenticated && whoAmI.person" class="d-flex" > + <active-school-term-select v-model="$root.activeSchoolTerm" /> <notification-list v-if="!whoAmI.person.isDummy" /> <account-menu :account-menu="accountMenu" @@ -65,6 +66,9 @@ </div> </v-app-bar> <v-main> + <active-school-term-banner + v-if="$root.activeSchoolTerm && !$root.activeSchoolTerm.current" + /> <div :class="{ 'main-container': true, @@ -259,6 +263,8 @@ import routesMixin from "../../mixins/routes"; import error404Mixin from "../../mixins/error404"; import { browsersRegex } from "virtual:supported-browsers"; +import ActiveSchoolTermSelect from "../school_term/ActiveSchoolTermSelect.vue"; +import ActiveSchoolTermBanner from "../school_term/ActiveSchoolTermBanner.vue"; export default { data() { @@ -326,6 +332,8 @@ export default { }, name: "App", components: { + ActiveSchoolTermBanner, + ActiveSchoolTermSelect, AccountMenu, ErrorPage, NotificationList, diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue index 54c0f4d181d42a1d8246a5a1f600000499b71e84..47b741fbeb122f49d6fdbcb0d276df4fcf0d3761 100644 --- a/aleksis/core/frontend/components/notifications/NotificationList.vue +++ b/aleksis/core/frontend/components/notifications/NotificationList.vue @@ -9,14 +9,13 @@ <template #activator="{ on, attrs }"> <v-btn icon - color="primary" + dark v-bind="attrs" v-on="on" :loading="$apollo.queries.myNotifications.loading" class="mx-2" > <v-icon - color="white" v-if=" myNotifications && myNotifications.person && diff --git a/aleksis/core/frontend/components/person/PersonOverview.vue b/aleksis/core/frontend/components/person/PersonOverview.vue index ac23625d38dcd34af93011bf6365721b3bf8df51..55826e5cc139eda682343ab4e8d7d2d87f1fdee5 100644 --- a/aleksis/core/frontend/components/person/PersonOverview.vue +++ b/aleksis/core/frontend/components/person/PersonOverview.vue @@ -263,7 +263,7 @@ <template v-for="widget in widgets"> <v-col - v-if="widget.shouldDisplay(person, currentSchoolTerm)" + v-if="widget.shouldDisplay(person, $root.activeSchoolTerm)" v-bind="widget.colProps" :key="widget.key" > @@ -271,7 +271,7 @@ <component :is="widget.component" :person="person" - :school-term="currentSchoolTerm" + :school-term="$root.activeSchoolTerm" :maximized="widgetSlug === widget.key" @maximize="maximizeWidget(widget.key)" @minimize="minimizeWidgets()" @@ -292,7 +292,6 @@ import PersonActions from "./PersonActions.vue"; import PersonAvatarClickbox from "./PersonAvatarClickbox.vue"; import PersonCollection from "./PersonCollection.vue"; -import gqlCurrentSchoolTerm from "../school_term/currentSchoolTerm.graphql"; import gqlPersonOverview from "./personOverview.graphql"; import { collections } from "aleksisAppImporter"; @@ -307,15 +306,9 @@ export default { PersonAvatarClickbox, PersonCollection, }, - apollo: { - currentSchoolTerm: { - query: gqlCurrentSchoolTerm, - }, - }, data() { return { query: gqlPersonOverview, - currentSchoolTerm: null, }; }, props: { diff --git a/aleksis/core/frontend/components/school_term/ActiveSchoolTermBanner.vue b/aleksis/core/frontend/components/school_term/ActiveSchoolTermBanner.vue new file mode 100644 index 0000000000000000000000000000000000000000..a0e795ede81b9e45cc10b9fbd98635b5404de064 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/ActiveSchoolTermBanner.vue @@ -0,0 +1,35 @@ +<script> +export default { + name: "ActiveSchoolTermBanner", +}; +</script> + +<template> + <v-banner + :single-line="!$vuetify.breakpoint.mobile" + v-bind="$attrs" + v-on="$listeners" + color="warning white--text" + icon="$warning" + icon-color="white" + id="banner" + > + {{ + $t("school_term.active_school_term.warning", { + termName: $root.activeSchoolTerm?.name, + }) + }} + + <template #actions> + <v-btn text color="black" @click="$root.activeSchoolTerm = null"> + {{ $t("school_term.active_school_term.select_action") }} + </v-btn> + </template> + </v-banner> +</template> + +<style> +#banner > .v-banner__wrapper { + padding-block: 4px; +} +</style> diff --git a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..f56bb19639844bfb7a8cbac560fb192a597df664 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue @@ -0,0 +1,156 @@ +<script> +import { + activeSchoolTerm, + schoolTerms, + setActiveSchoolTerm, +} from "./activeSchoolTerm.graphql"; +import loadingMixin from "../../mixins/loadingMixin"; +export default { + name: "ActiveSchoolTermSelect", + mixins: [loadingMixin], + apollo: { + schoolTerms: { + query: schoolTerms, + }, + activeSchoolTerm: { + query: activeSchoolTerm, + result() { + this.$emit("input", this.activeSchoolTerm); + }, + }, + }, + props: { + affectedQuery: { + type: Number, + default: 0, + }, + value: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + activeSchoolTerm: null, + schoolTerms: [], + showSuccess: false, + }; + }, + computed: { + schoolTerm: { + get() { + return this.activeSchoolTerm?.id; + }, + set(value) { + if (this.activeSchoolTerm?.id === value) { + return; + } + + this.handleLoading(true); + + this.$apollo + .mutate({ + mutation: setActiveSchoolTerm, + variables: { id: value }, + update: (store, data) => { + const newTerm = data.data.setActiveSchoolTerm; + + // Update cached data + store.writeQuery({ query: activeSchoolTerm, data: newTerm }); + this.$emit("input", newTerm); + }, + }) + .catch((error) => { + this.handleMutationError(error); + }) + .finally(() => { + this.handleLoading(false); + this.showSuccess = true; + setTimeout(() => { + this.showSuccess = false; + }, 2000); + }); + }, + }, + }, + watch: { + value(value) { + if (!value) { + value = this.schoolTerms.find((term) => term.current); + } + if (Object.hasOwn(value, "activeSchoolTerm")) { + value = value.activeSchoolTerm; + } + if (Object.hasOwn(value, "id")) { + value = value.id; + } + + if (this.schoolTerm === value) { + return; + } + + this.schoolTerm = value; + }, + }, +}; +</script> + +<template> + <v-menu offset-y :close-on-content-click="false"> + <template #activator="{ on, attrs }"> + <v-btn + icon + v-bind="attrs" + v-on="on" + dark + :loading="$apollo.queries.activeSchoolTerm.loading" + > + <v-icon v-if="activeSchoolTerm?.current">$schoolTerm</v-icon> + <v-icon v-else>mdi-calendar-alert-outline</v-icon> + </v-btn> + </template> + <v-list :disabled="loading"> + <v-list-item disabled> + <v-list-item-content> + <v-list-item-title> + {{ $t("school_term.active_school_term.title") }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("school_term.active_school_term.subtitle") }} + </v-list-item-subtitle> + </v-list-item-content> + + <v-avatar> + <v-progress-circular + v-if="loading" + indeterminate + :size="16" + :width="2" + /> + <v-icon v-else-if="showSuccess" color="success">$success</v-icon> + </v-avatar> + </v-list-item> + + <v-list-item-group v-model="schoolTerm" :mandatory="!!activeSchoolTerm"> + <v-list-item + v-for="term in schoolTerms" + :key="term.id" + :value="term.id" + > + <v-list-item-content> + <v-list-item-title> + {{ term.name }} + </v-list-item-title> + </v-list-item-content> + + <v-list-item-action v-if="term.current"> + <v-chip label color="secondary"> + {{ $t("school_term.current") }} + </v-chip> + </v-list-item-action> + </v-list-item> + </v-list-item-group> + </v-list> + </v-menu> +</template> diff --git a/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql b/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql new file mode 100644 index 0000000000000000000000000000000000000000..95b5fc170064e4dea9a24a17f3b10ad4bccb5af8 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql @@ -0,0 +1,37 @@ +query activeSchoolTerm { + activeSchoolTerm { + id + name + dateStart + dateEnd + current + canEdit + canDelete + } +} + +query schoolTerms { + schoolTerms { + id + name + dateStart + dateEnd + current + canEdit + canDelete + } +} + +mutation setActiveSchoolTerm($id: ID!) { + setActiveSchoolTerm(id: $id) { + activeSchoolTerm { + id + name + dateStart + dateEnd + current + canEdit + canDelete + } + } +} diff --git a/aleksis/core/frontend/components/school_term/currentSchoolTerm.graphql b/aleksis/core/frontend/components/school_term/currentSchoolTerm.graphql deleted file mode 100644 index 82a7741f54770fc1a7edb7d6ce5ebcf74ffbb1f5..0000000000000000000000000000000000000000 --- a/aleksis/core/frontend/components/school_term/currentSchoolTerm.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query currentSchoolTerm { - currentSchoolTerm { - id - name - dateStart - dateEnd - canEdit - canDelete - } -} diff --git a/aleksis/core/frontend/index.js b/aleksis/core/frontend/index.js index 21ebd05d93130a30d115bf7f78ba0db0a6595d26..00411a66a968a2e1d1eda36987cc325000634610 100644 --- a/aleksis/core/frontend/index.js +++ b/aleksis/core/frontend/index.js @@ -80,6 +80,7 @@ const app = new Vue({ permissions: [], permissionNames: [], frequentCeleryPolling: false, + activeSchoolTerm: null, }), computed: { matchedComponents() { diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json index fb3ae338ac4bb71428c2d5f8feb764904353dbbb..70c4b17328c9d85fc97ad8307a0601c578545bd2 100644 --- a/aleksis/core/frontend/messages/de.json +++ b/aleksis/core/frontend/messages/de.json @@ -407,7 +407,14 @@ "menu_title": "Schuljahre", "name": "Name", "title": "Schuljahr", - "title_plural": "Schuljahre" + "title_plural": "Schuljahre", + "current": "Aktuell", + "active_school_term": { + "title": "Aktives Schuljahr", + "subtitle": "Die Auswahl wird auf allen Seiten in AlekSIS berücksichtigt.", + "warning": "Hinweis: Sie sehen aktuell Daten aus einem anderen Schuljahr ({termName}). Informationen können daher veraltet sein und entsprechen möglicherweise nicht dem aktuellen Stand.", + "select_action": "Aktuelles auswählen" + } }, "selection": { "num_items_selected": "Keine Objekte ausgewählt | 1 Objekt ausgewählt | {n} Objekte ausgewählt" diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index efdb3a89575d9d2a816db183862e468ace66dcca..dda331d9ae8105f1ac43b7bdf8c011859597271d 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -348,7 +348,14 @@ "menu_title": "School Terms", "name": "Name", "title": "School Term", - "title_plural": "School Terms" + "title_plural": "School Terms", + "current": "Current", + "active_school_term": { + "title": "Active School Term", + "subtitle": "This selection affects all pages in AlekSIS.", + "warning": "Warning: You are currently viewing data from a different school term ({termName}). Information may be outdated and may not reflect the current situation.", + "select_action": "Select current" + } }, "selection": { "num_items_selected": "No items selected | 1 item selected | {n} items selected" diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 116d7936687352665f5f89a18f40af9ecf91d434..c90c3facf230c73ec0685610b22663433ab21eeb 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -317,7 +317,7 @@ const routes = [ meta: { inMenu: true, titleKey: "school_term.menu_title", - icon: "mdi-calendar-range-outline", + icon: "$schoolTerm", iconActive: "mdi-calendar-range", permission: "core.view_schoolterm_rule", }, diff --git a/aleksis/core/management/commands/convert_urls_to_routes.py b/aleksis/core/management/commands/convert_urls_to_routes.py index e484988ef8bb2c37014daa91d18ab4ee41864255..f6cd8b8297e8b114c2030933ac826266f3e1274b 100644 --- a/aleksis/core/management/commands/convert_urls_to_routes.py +++ b/aleksis/core/management/commands/convert_urls_to_routes.py @@ -45,7 +45,7 @@ class Command(BaseCommand): for url in urlpatterns: # Convert route name and url pattern to vue-router format - menu = menu_by_urls[url.name] if url.name in menu_by_urls else None + menu = menu_by_urls.get(url.name, None) route_name = f"{app_camel_case}.{camelcase(url.name)}" url_pattern = url.pattern._route new_url_pattern_list = [] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 2d41b0438ab1d34420f835b097f8f451ac9b43fb..4acdb6b6724610c6a9328049394e4935399a232f 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1698,10 +1698,8 @@ class BirthdayEvent(CalendarEventMixin): @classmethod def value_description(cls, reference_object: Person, request: HttpRequest | None = None) -> str: - return ("{name} was born on {birthday}.").format( - name=reference_object.addressing_name, - birthday=date_format(reference_object.date_of_birth), - ) + return f"{reference_object.addressing_name} \ + was born on {date_format(reference_object.date_of_birth)}." @classmethod def value_start_datetime( diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index d91cef5af4dbb3cd9271a70228af200f4f676253..c6cdaa70407cca99db94df1158010514c3e8141a 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -23,6 +23,7 @@ from ..models import ( ) from ..util.apps import AppConfig from ..util.core_helpers import ( + get_active_school_term, get_allowed_object_ids, get_app_module, get_app_packages, @@ -68,6 +69,7 @@ from .school_term import ( SchoolTermBatchDeleteMutation, SchoolTermBatchPatchMutation, SchoolTermType, + SetActiveSchoolTermMutation, ) from .search import SearchResultType from .system_properties import SystemPropertiesType @@ -115,8 +117,8 @@ class Query(graphene.ObjectType): rooms = FilterOrderList(RoomType) room_by_id = graphene.Field(RoomType, id=graphene.ID()) + active_school_term = graphene.Field(SchoolTermType) school_terms = FilterOrderList(SchoolTermType) - current_school_term = graphene.Field(SchoolTermType) holidays = FilterOrderList(HolidayType) calendar = graphene.Field(CalendarBaseType) @@ -282,6 +284,10 @@ class Query(graphene.ObjectType): return room_object + @staticmethod + def resolve_active_school_term(root, info, **kwargs): + return get_active_school_term(info.context) + @staticmethod def resolve_calendar(root, info, **kwargs): return True @@ -310,6 +316,7 @@ class Mutation(graphene.ObjectType): create_school_terms = SchoolTermBatchCreateMutation.Field() delete_school_terms = SchoolTermBatchDeleteMutation.Field() update_school_terms = SchoolTermBatchPatchMutation.Field() + set_active_school_term = SetActiveSchoolTermMutation.Field() create_holidays = HolidayBatchCreateMutation.Field() delete_holidays = HolidayBatchDeleteMutation.Field() diff --git a/aleksis/core/schema/group.py b/aleksis/core/schema/group.py index 85ba440092798471701fdc999a1da2b2e198f88a..32832a512e84b04a57e3d1f72ed20f8d9b99ee5a 100644 --- a/aleksis/core/schema/group.py +++ b/aleksis/core/schema/group.py @@ -5,7 +5,7 @@ from graphene_django import DjangoObjectType from guardian.shortcuts import get_objects_for_user from ..models import Group, Person -from ..util.core_helpers import has_person +from ..util.core_helpers import filter_active_school_term, has_person from .base import BaseBatchDeleteMutation, DjangoFilterMixin, PermissionsTypeMixin @@ -123,7 +123,7 @@ class GroupType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): ), ) - return qs + return filter_active_school_term(info.context, qs) class GroupBatchDeleteMutation(BaseBatchDeleteMutation): diff --git a/aleksis/core/schema/school_term.py b/aleksis/core/schema/school_term.py index 8dd36b47a8978bd448a28ba08f8948f2564cd2bd..6887097ff9ebcfd529887da8def1652a437217b1 100644 --- a/aleksis/core/schema/school_term.py +++ b/aleksis/core/schema/school_term.py @@ -1,3 +1,4 @@ +import graphene from graphene_django import DjangoObjectType from ..models import SchoolTerm @@ -20,12 +21,18 @@ class SchoolTermType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): } fields = ("id", "name", "date_start", "date_end") + current = graphene.Boolean() + @classmethod def get_queryset(cls, queryset, info, **kwargs): if not info.context.user.has_perm("core.fetch_schoolterms_rule"): return [] return queryset + @staticmethod + def resolve_current(root, info): + return (current := SchoolTerm.current) and root.pk == current.pk + class SchoolTermBatchCreateMutation(BaseBatchCreateMutation): class Meta: @@ -45,3 +52,17 @@ class SchoolTermBatchPatchMutation(BaseBatchPatchMutation): model = SchoolTerm permissions = ("core.edit_schoolterm_rule",) only_fields = ("id", "name", "date_start", "date_end") + + +class SetActiveSchoolTermMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) # noqa + + active_school_term = graphene.Field(SchoolTermType) + + @classmethod + def mutate(cls, root, info, id): # noqa + school_term = SchoolTerm.objects.get(id=id) + info.context.session["active_school_term"] = school_term.pk + + return SetActiveSchoolTermMutation(active_school_term=school_term) diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index d9331c3f00668de5ddf917b7073cf47c494d6ecb..bf44b9e1816498aec55760e4e494d9935d17a55d 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -11,7 +11,7 @@ from warnings import warn from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.core.files import File -from django.db.models import Model, QuerySet +from django.db.models import Model, Q, QuerySet from django.http import HttpRequest from django.shortcuts import get_object_or_404 from django.urls import reverse @@ -568,3 +568,25 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed): if start and end: return events.between(start, end) return events.all() + + +def get_active_school_term(request): + from ..models import SchoolTerm + + pk = request.session.get("active_school_term", None) + if pk: + return SchoolTerm.objects.get(pk=pk) + else: + return SchoolTerm.current + + +def filter_active_school_term( + request, + q, + school_term_field="school_term", +): + if active_school_term := get_active_school_term(request): + return q.filter( + Q(**{school_term_field: active_school_term}) | Q(**{school_term_field: None}) + ) + return q diff --git a/aleksis/core/util/tables.py b/aleksis/core/util/tables.py index 8bb9fd5c0b4212a1361ebd12b6acd5087731ca58..00bcc95297f2481eb81e81395bca2c7e1b2b368f 100644 --- a/aleksis/core/util/tables.py +++ b/aleksis/core/util/tables.py @@ -30,7 +30,7 @@ class MaterializeCheckboxColumn(CheckBoxColumn): attrs = dict(default, **(specific or general or {})) attrs = computed_values(attrs, kwargs={"record": record, "value": value}) return mark_safe( # noqa - "<label><input %s/><span></span</label>" % AttributeDict(attrs).as_html() + f"<label><input {AttributeDict(attrs).as_html()}/><span></span</label>" )