diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 29a72a1804db5ee013a10bf92ea69f91de399ace..8612ce0d2f0be6b31d03a76fe45e6f92822dcaab 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,28 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +~~~~~ + +* Introduce Holiday model to track information about holidays. + +Changes +~~~~~~~ + +* The frontend is now able to display headings in the main toolbar. + +Fixed +~~~~~ + +* Default translations from vuetify were not loaded. +* Browser locale was not the default locale in the entire frontend. +* In some cases, some items in the sidenav menu were not shown due to its height being higher than the visible page area. +* The search bar in the sidenav menu is shown even though the user has no permission to see it. +* Add permission check to accept invitation menu point in order to hide it when this feature is disabled. +* Metrics endpoint for Prometheus was at the wrong URL. +* Polling behavior of the whoAmI and permission queries was fixed. +* Confirmation e-mail contained a wrong link. + `3.0`_ - 2022-05-11 ------------------- @@ -19,6 +41,7 @@ Added * Provide API endpoint for system status. * [Dev] UpdateIndicator Vue Component to display the status of interactive pages * [Dev] DeleteDialog Vue Component to unify item deletion in the new frontend +* Use build-in mechanism in Apollo for GraphQL batch querying. Changed @@ -51,7 +74,7 @@ Fixed * Backend cleanup task for Celery wasn't working. * URLs in invitation email were broken. * Invitation view didn't work. -* Invitation emails were using wrong styling. +* Invitation emails were using wrong styling. * GraphQL queries and mutations did not log exceptions. `3.0b3`_ - 2023-03-19 diff --git a/aleksis/core/frontend/app/apollo.js b/aleksis/core/frontend/app/apollo.js index 267997b9f93ba8d992a90093a93e650f32995811..519bc24aa5d7920d6433d4eebef1b38673753907 100644 --- a/aleksis/core/frontend/app/apollo.js +++ b/aleksis/core/frontend/app/apollo.js @@ -2,11 +2,12 @@ * Configuration for Apollo provider, client, and caches. */ -import { ApolloClient, HttpLink, from } from "@/apollo-boost"; +import { ApolloClient, from } from "@/apollo-boost"; import { RetryLink } from "@/apollo-link-retry"; import { persistCache, LocalStorageWrapper } from "@/apollo3-cache-persist"; import { InMemoryCache } from "@/apollo-cache-inmemory"; +import { BatchHttpLink } from "@/apollo-link-batch-http"; // Cache for GraphQL query results in memory and persistent across sessions const cache = new InMemoryCache(); @@ -33,14 +34,17 @@ const links = [ // Automatically retry failed queries new RetryLink(), // Finally, the HTTP link to the real backend (Django) - new HttpLink({ + new BatchHttpLink({ uri: getGraphqlURL(), + batchInterval: 200, + batchDebounce: true, }), ]; /** Upstream Apollo GraphQL client */ const apolloClient = new ApolloClient({ cache, + shouldBatch: true, link: from(links), }); diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js index b5d498f29c1c1a7bcfd2e59f64c695cadfcd9027..76c00c47bc47b17ed2f37d075a3b3edd259f21dd 100644 --- a/aleksis/core/frontend/app/dateTimeFormats.js +++ b/aleksis/core/frontend/app/dateTimeFormats.js @@ -6,6 +6,13 @@ const dateTimeFormats = { month: "short", day: "numeric", }, + shortDateTime: { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }, long: { year: "numeric", month: "long", @@ -37,6 +44,13 @@ const dateTimeFormats = { month: "short", day: "numeric", }, + shortDateTime: { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + }, long: { year: "numeric", month: "long", diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index 0d8e5b215535324d5765bcaca2ad83df94021aa8..9c3a9ced9b3e950a8c4955b96bc6afa5f79355b9 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -246,12 +246,17 @@ export default { systemProperties: gqlSystemProperties, whoAmI: { query: gqlWhoAmI, + pollInterval: 30000, + result({ data }) { + if (data && data.whoAmI) { + this.$root.permissions = data.whoAmI.permissions; + } + }, variables() { return { - permissions: this.permissionNames, + permissions: this.$root.permissionNames, }; }, - pollInterval: 10000, }, messages: { query: gqlMessages, diff --git a/aleksis/core/frontend/components/app/LanguageForm.vue b/aleksis/core/frontend/components/app/LanguageForm.vue index 12acbe613e94a23e25f5d1195b0badf420d92aa6..cdef5932231e5b0401296b6918d009eb544588ec 100644 --- a/aleksis/core/frontend/components/app/LanguageForm.vue +++ b/aleksis/core/frontend/components/app/LanguageForm.vue @@ -33,17 +33,33 @@ export default { type: Array, required: true, }, + defaultLanguage: { + type: Object, + required: true, + }, }, methods: { setLanguage: function (languageOption) { document.cookie = languageOption.cookie; this.$i18n.locale = languageOption.code; this.$vuetify.lang.current = languageOption.code; + this.language = languageOption; }, nameForMenu: function (item) { return `${item.nameLocal} (${item.code})`; }, }, + mounted() { + if ( + this.availableLanguages.filter((lang) => lang.code === this.$i18n.locale) + .length === 0 + ) { + console.warn( + `Unsupported language ${this.$i18n.locale} selected, defaulting to ${this.defaultLanguage.code}` + ); + this.setLanguage(this.defaultLanguage); + } + }, name: "LanguageForm", }; </script> diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue index 0975c580cda88d03fd8a5d34b2fb16425ece69ee..e2594aae9eb9d561a710530a3d87e345e2791c74 100644 --- a/aleksis/core/frontend/components/app/SideNav.vue +++ b/aleksis/core/frontend/components/app/SideNav.vue @@ -1,5 +1,10 @@ <template> - <v-navigation-drawer app :value="value" @input="$emit('input', $event)"> + <v-navigation-drawer + app + :value="value" + height="100dvh" + @input="$emit('input', $event)" + > <v-list nav dense shaped> <v-list-item class="logo"> <a @@ -10,7 +15,7 @@ <brand-logo :site-preferences="systemProperties.sitePreferences" /> </a> </v-list-item> - <v-list-item class="search"> + <v-list-item v-if="checkPermission('core.search_rule')" class="search"> <sidenav-search /> </v-list-item> <v-list-item-group :value="$route.name" v-if="sideNavMenu"> @@ -82,6 +87,7 @@ <v-spacer /> <language-form :available-languages="systemProperties.availableLanguages" + :default-language="systemProperties.defaultLanguage" /> <v-spacer /> </div> @@ -94,6 +100,8 @@ import BrandLogo from "./BrandLogo.vue"; import LanguageForm from "./LanguageForm.vue"; import SidenavSearch from "./SidenavSearch.vue"; +import permissionsMixin from "../../mixins/permissions.js"; + export default { name: "SideNav", components: { @@ -106,6 +114,10 @@ export default { systemProperties: { type: Object, required: true }, value: { type: Boolean, required: true }, }, + mixins: [permissionsMixin], + mounted() { + this.addPermissions(["core.search_rule"]); + }, }; </script> diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql index 99533650b65369f4a07c330399a2f452a815b763..b8ec991bda2b1b689c97f2fb339dafce9d0e1175 100644 --- a/aleksis/core/frontend/components/app/systemProperties.graphql +++ b/aleksis/core/frontend/components/app/systemProperties.graphql @@ -6,6 +6,12 @@ nameLocal cookie } + defaultLanguage { + code + nameTranslated + nameLocal + cookie + } sitePreferences { themePrimary themeSecondary diff --git a/aleksis/core/frontend/components/app/whoAmI.graphql b/aleksis/core/frontend/components/app/whoAmI.graphql index 0b2877bd2cf15b5134c8e70978fb6b2218414e3a..fff7344d06817d73a37ede3f8698afaaa06e1ea6 100644 --- a/aleksis/core/frontend/components/app/whoAmI.graphql +++ b/aleksis/core/frontend/components/app/whoAmI.graphql @@ -1,4 +1,4 @@ -query ($permissions: [String]!) { +query whoAmI($permissions: [String]!) { whoAmI { username isAuthenticated diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..2db52ae5ef86ecf4534aee70ffd7d9939dffe453 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue @@ -0,0 +1,64 @@ +<template> + <v-menu + v-model="model" + :close-on-content-click="false" + :activator="selectedElement" + offset-x + > + <v-card min-width="350px" flat> + <v-toolbar :color="selectedEvent.color" dark dense> + <v-toolbar-title> + <slot name="title" :selected-event="selectedEvent">{{ + selectedEvent.name + }}</slot> + </v-toolbar-title> + <v-spacer></v-spacer> + <slot name="badge" :selected-event="selectedEvent"> + <v-chip + v-if="selectedEvent.status === 'CANCELLED' && !withoutBadge" + color="error" + label + > + <v-avatar left> + <v-icon>mdi-cancel</v-icon> + </v-avatar> + {{ $t("calendar.cancelled") }} + </v-chip> + </slot> + </v-toolbar> + <slot name="time" :selected-event="selectedEvent"> + <v-card-text v-if="!withoutTime"> + <v-icon left>mdi-calendar-today-outline</v-icon> + <span v-if="selectedEvent.start !== selectedEvent.end"> + {{ $d(selectedEvent.start, "shortDateTime") }} – + {{ $d(selectedEvent.end, "shortDateTime") }} + </span> + <span v-else> {{ $d(selectedEvent.start, "shortDateTime") }}</span> + </v-card-text> + </slot> + <slot name="description" :selected-event="selectedEvent"> + <v-divider v-if="selectedEvent.description && !withoutDescription" /> + <v-card-text + class="d-flex" + v-if="selectedEvent.description && !withoutDescription" + > + <div> + <v-icon left>mdi-card-text-outline</v-icon> + </div> + <div style="white-space: pre-line"> + {{ selectedEvent.description }} + </div> + </v-card-text> + </slot> + </v-card> + </v-menu> +</template> + +<script> +import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js"; + +export default { + name: "BaseCalendarFeedDetails", + mixins: [calendarFeedDetailsMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..510e2af17160f6d2cbcacbf3d640cb18d1f9df5c --- /dev/null +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue @@ -0,0 +1,36 @@ +<template> + <div + class="mx-1 text-truncate" + :style=" + event.status === 'CANCELLED' ? 'text-decoration: line-through;' : '' + " + > + <slot name="time" v-bind="$props"> + <span + v-if=" + calendarType === 'month' && eventParsed.start.hasTime && !withoutTime + " + class="mr-1 font-weight-bold" + >{{ eventParsed.start.time }}</span + > + </slot> + <slot name="icon" v-bind="$props"> + <v-icon v-if="icon" x-small color="white" class="mx-1 left"> + {{ icon }} + </v-icon> + </slot> + + <slot name="title" v-bind="$props"> + {{ event.name }} + </slot> + </div> +</template> + +<script> +import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js"; + +export default { + name: "BaseCalendarFeedEventBar", + mixins: [calendarFeedEventBarMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..575475db559c39a24d8ffebf645b17d666594e91 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue @@ -0,0 +1,317 @@ +<template> + <div class="mt-4 mb-4"> + <v-skeleton-loader + v-if="$apollo.queries.calendarFeeds.loading && calendarFeeds.length === 0" + type="date-picker-options, actions" + /> + <div v-else> + <h1 + class="mb-4 mx-4" + v-if="$vuetify.breakpoint.mdAndDown && $refs.calendar" + > + {{ $refs.calendar.title }} + </h1> + <v-row align="stretch"> + <v-col + cols="12" + sm="4" + lg="3" + xl="2" + align-self="center" + class="d-flex justify-center" + > + <v-btn icon class="mx-2" @click="$refs.calendar.prev()"> + <v-icon>mdi-chevron-left</v-icon> + </v-btn> + <v-btn outlined text class="mx-2" @click="calendarFocus = ''"> + <v-icon left>mdi-calendar-today-outline</v-icon> + {{ $t("calendar.today") }} + </v-btn> + <v-btn icon class="mx-2" @click="$refs.calendar.next()"> + <v-icon>mdi-chevron-right</v-icon> + </v-btn> + </v-col> + <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center"> + <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1> + </v-col> + <v-col + v-if="$vuetify.breakpoint.mdAndDown" + cols="12" + sm="4" + align-self="center" + class="d-flex justify-center" + > + <button-menu + icon="mdi-calendar-check-outline" + text-translation-key="calendar.select" + > + <calendar-select + v-model="selectedCalendarFeedNames" + :calendar-feeds="calendarFeeds" + /> + </button-menu> + </v-col> + <v-spacer v-if="$vuetify.breakpoint.lgAndUp" /> + <v-col + cols="12" + sm="4" + lg="3" + align-self="center" + :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'" + > + <v-btn-toggle dense v-model="currentCalendarType" class="mx-2"> + <v-btn + v-for="calendarType in availableCalendarTypes" + :value="calendarType.type" + :key="calendarType.type" + > + <span class="hidden-sm-and-down">{{ + nameForMenu(calendarType) + }}</span> + <v-icon :right="$vuetify.breakpoint.mdAndUp">{{ + "mdi-" + calendarType.icon + }}</v-icon> + </v-btn> + </v-btn-toggle> + </v-col> + </v-row> + <v-row> + <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2"> + <v-list flat> + <calendar-select + v-model="selectedCalendarFeedNames" + :calendar-feeds="calendarFeeds" + /> + </v-list> + </v-col> + <v-col lg="9" xl="10"> + <v-sheet height="600"> + <v-expand-transition> + <v-progress-linear + v-if="$apollo.queries.calendarFeeds.loading" + indeterminate + /> + </v-expand-transition> + <v-calendar + ref="calendar" + v-model="calendarFocus" + show-week + :events="events" + :type="currentCalendarType" + :event_color="getColorForEvent" + @click:date="viewDay" + @click:day="viewDay" + @click:more="viewDay" + @click:event="viewEvent" + @change="fetchMoreCalendarEvents" + > + <template #event="{ event, eventParsed, timed }"> + <component + :is="eventBarComponentForFeed(event.calendarFeedName)" + :event="event" + :event-parsed="eventParsed" + :calendar-type="currentCalendarType" + /> + </template> + </v-calendar> + <component + v-if="calendarFeeds && selectedEvent" + :is="detailComponentForFeed(selectedEvent.calendarFeedName)" + v-model="selectedOpen" + :selected-element="selectedElement" + :selected-event="selectedEvent" + /> + </v-sheet> + </v-col> + </v-row> + </div> + </div> +</template> + +<script> +import ButtonMenu from "../generic/ButtonMenu.vue"; +import CalendarSelect from "./CalendarSelect.vue"; +import GenericCalendarFeedDetails from "./GenericCalendarFeedDetails.vue"; +import GenericCalendarFeedEventBar from "./GenericCalendarFeedEventBar.vue"; + +import { + calendarFeedDetailComponents, + calendarFeedEventBarComponents, +} from "aleksisAppImporter"; + +import gqlCalendarOverview from "./calendarOverview.graphql"; + +export default { + name: "CalendarOverview", + components: { + ButtonMenu, + CalendarSelect, + }, + data() { + return { + calendarFocus: "", + calendarFeeds: [], + selectedCalendarFeedNames: [], + currentCalendarType: "week", + selectedEvent: {}, + selectedElement: null, + selectedOpen: false, + availableCalendarTypes: [ + { + type: "month", + translationKey: "calendar.month", + icon: "calendar-month-outline", + }, + { + type: "week", + translationKey: "calendar.week", + icon: "calendar-week-outline", + }, + { + type: "day", + translationKey: "calendar.day", + icon: "calendar-today-outline", + }, + ], + fetchedDateRange: { start: null, end: null }, + }; + }, + apollo: { + calendarFeeds: { + query: gqlCalendarOverview, + skip: true, + }, + }, + computed: { + events() { + return this.calendarFeeds + .filter((c) => this.selectedCalendarFeedNames.includes(c.name)) + .flatMap((cf) => + cf.feed.events.map((event) => ({ + ...event, + category: cf.verboseName, + calendarFeedName: cf.name, + start: new Date(event.start), + end: new Date(event.end), + color: event.color ? event.color : cf.color, + timed: !event.allDay, + })) + ); + }, + }, + methods: { + nameForMenu: function (item) { + return this.$t(item.translationKey); + }, + viewDay({ date }) { + this.calendarFocus = date; + this.currentCalendarType = "day"; + }, + viewEvent({ nativeEvent, event }) { + const open = () => { + this.selectedEvent = event; + this.selectedElement = nativeEvent.target; + requestAnimationFrame(() => + requestAnimationFrame(() => (this.selectedOpen = true)) + ); + }; + + if (this.selectedOpen) { + this.selectedOpen = false; + requestAnimationFrame(() => requestAnimationFrame(() => open())); + } else { + open(); + } + + nativeEvent.stopPropagation(); + }, + detailComponentForFeed(feedName) { + if ( + this.calendarFeeds && + feedName && + Object.keys(calendarFeedDetailComponents).includes(feedName + "details") + ) { + return calendarFeedDetailComponents[feedName + "details"]; + } + return GenericCalendarFeedDetails; + }, + eventBarComponentForFeed(feedName) { + if ( + this.calendarFeeds && + feedName && + Object.keys(calendarFeedEventBarComponents).includes( + feedName + "eventbar" + ) + ) { + return calendarFeedEventBarComponents[feedName + "eventbar"]; + } + return GenericCalendarFeedEventBar; + }, + getColorForEvent(event) { + return event.color; + }, + fetchMoreCalendarEvents({ start, end }) { + // Get the start and end dates of the current date range shown in the calendar + let extendedStart = this.$refs.calendar.getStartOfWeek(start).date; + let extendedEnd = this.$refs.calendar.getEndOfWeek(end).date; + + let olderStart = extendedStart < this.fetchedDateRange.start; + let youngerEnd = extendedEnd > this.fetchedDateRange.end; + + if (this.calendarFeeds.length === 0) { + // No calendar feeds have been fetched yet, + // so fetch all events in the current date range + + this.$apollo.queries.calendarFeeds.setVariables({ + start: extendedStart, + end: extendedEnd, + }); + this.$apollo.queries.calendarFeeds.skip = false; + this.fetchedDateRange = { start: extendedStart, end: extendedEnd }; + } else if (olderStart || youngerEnd) { + // Define newly fetched date range + let newStart = olderStart ? extendedStart : this.fetchedDateRange.start; + let newEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.end; + + // Define date range to fetch + let fetchStart = olderStart ? extendedStart : this.fetchedDateRange.end; + let fetchEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.start; + + this.$apollo.queries.calendarFeeds.fetchMore({ + variables: { + start: fetchStart, + end: fetchEnd, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + let previousCalendarFeeds = previousResult.calendarFeeds; + let newCalendarFeeds = fetchMoreResult.calendarFeeds; + + previousCalendarFeeds.forEach((calendarFeed, i, calendarFeeds) => { + // Get all events except those that are updated + let keepEvents = calendarFeed.feed.events.filter( + (event) => event.end < fetchStart || event.start > fetchEnd + ); + + /// Update the events of the calendar feed + calendarFeeds[i].feed.events = [ + ...keepEvents, + ...newCalendarFeeds[i].feed.events, + ]; + }); + return { + calendarFeeds: previousCalendarFeeds, + }; + }, + }); + + this.fetchedDateRange = { start: newStart, end: newEnd }; + } + }, + }, + mounted() { + this.$refs.calendar.move(0); + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..04421cb09ee6c9e6a23bd59a5ad4eb5e7c37d938 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue @@ -0,0 +1,86 @@ +<template> + <v-list-item-group multiple v-model="model"> + <v-subheader> + <v-checkbox + :label="$t('actions.select_all')" + :indeterminate="someSelected" + :input-value="allSelected" + @change="toggleAll" + /> + </v-subheader> + <v-list-item + v-for="calendarFeed in calendarFeeds" + :key="calendarFeed.name" + :value="calendarFeed.name" + > + <template #default="{ active }"> + <v-list-item-action> + <v-checkbox :input-value="active"></v-checkbox> + </v-list-item-action> + + <v-list-item-icon> + <v-icon class="mr-2" :color="calendarFeed.color"> mdi-circle </v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ calendarFeed.verboseName }} + </v-list-item-title> + </v-list-item-content> + + <v-list-item-action> + <copy-to-clipboard-button + :text="calendarFeed.url" + :tooltip-help-text="$t('calendar.ics_to_clipboard')" + /> + </v-list-item-action> + </template> + </v-list-item> + </v-list-item-group> +</template> + +<script> +import CopyToClipboardButton from "../generic/CopyToClipboardButton.vue"; + +export default { + name: "CalendarSelect", + props: { + calendarFeeds: { + type: Array, + required: true, + }, + value: { + type: Array, + required: true, + }, + }, + components: { + CopyToClipboardButton, + }, + computed: { + model: { + get() { + return this.value; + }, + set(value) { + this.$emit("input", value); + }, + }, + someSelected() { + return this.model.length > 0 && !this.allSelected; + }, + allSelected() { + return this.model.length === this.calendarFeeds.length; + }, + }, + methods: { + toggleAll(newValue) { + if (newValue) { + this.model = this.calendarFeeds.map((feed) => feed.name); + } else { + this.model = []; + } + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c890629fb1662ccfa21102194d6dbbf4f2c40df --- /dev/null +++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue @@ -0,0 +1,14 @@ +<template> + <base-calendar-feed-details v-bind="$props" /> +</template> + +<script> +import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js"; +import BaseCalendarFeedDetails from "./BaseCalendarFeedDetails.vue"; + +export default { + name: "GenericCalendarFeedDetails", + components: { BaseCalendarFeedDetails }, + mixins: [calendarFeedDetailsMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..251b7a8b929f3b67ada0e4a207aefef16d500149 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedEventBar.vue @@ -0,0 +1,14 @@ +<template> + <base-calendar-feed-event-bar v-bind="$props" /> +</template> + +<script> +import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js"; +import BaseCalendarFeedEventBar from "./BaseCalendarFeedEventBar.vue"; + +export default { + name: "GenericCalendarFeedEventBar", + components: { BaseCalendarFeedEventBar }, + mixins: [calendarFeedEventBarMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/calendarOverview.graphql b/aleksis/core/frontend/components/calendar/calendarOverview.graphql new file mode 100644 index 0000000000000000000000000000000000000000..061ab3872572d3c8e98ac1e75bc236f664f62000 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql @@ -0,0 +1,21 @@ +query ($start: Date, $end: Date) { + calendarFeeds { + name + verboseName + description + url + color + feed { + events(start: $start, end: $end) { + name + start + end + color + description + uid + allDay + status + } + } + } +} diff --git a/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue b/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..f651ff2d11fa189b057b1adc6a613b288ae84604 --- /dev/null +++ b/aleksis/core/frontend/components/calendar_feeds/details/BirthdaysDetails.vue @@ -0,0 +1,24 @@ +<template> + <base-calendar-feed-details v-bind="$props" without-time> + <template #description="{ selectedEvent }"> + <v-divider /> + <v-card-text> + <span> + <v-icon class="mr-2">mdi-cake-variant-outline</v-icon> + {{ $d(selectedEvent.start) }} + </span> + </v-card-text> + </template> + </base-calendar-feed-details> +</template> + +<script> +import calendarFeedDetailsMixin from "../../../mixins/calendarFeedDetails.js"; +import BaseCalendarFeedDetails from "../../calendar/BaseCalendarFeedDetails.vue"; + +export default { + name: "BirthdaysDetails", + components: { BaseCalendarFeedDetails }, + mixins: [calendarFeedDetailsMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue b/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..d0f89cfae0695cba0033722dda8d66d5686205a6 --- /dev/null +++ b/aleksis/core/frontend/components/calendar_feeds/event_bar/BirthdaysEventBar.vue @@ -0,0 +1,17 @@ +<template> + <base-calendar-feed-event-bar + v-bind="$props" + icon="mdi-cake-variant-outline" + /> +</template> + +<script> +import calendarFeedEventBarMixin from "../../../mixins/calendarFeedEventBar.js"; +import BaseCalendarFeedEventBar from "../../calendar/BaseCalendarFeedEventBar.vue"; + +export default { + name: "BirthdaysEventBar", + components: { BaseCalendarFeedEventBar }, + mixins: [calendarFeedEventBarMixin], +}; +</script> diff --git a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue index 410e57fda0eca36d3acaca7fc0318174fd71d318..912fb779241ef577c6f900abc78a26008df008f6 100644 --- a/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue +++ b/aleksis/core/frontend/components/celery_progress/CeleryProgressBottom.vue @@ -47,7 +47,7 @@ export default { apollo: { celeryProgressByUser: { query: gqlCeleryProgressButton, - pollInterval: 1000, + pollInterval: 30000, }, }, }; diff --git a/aleksis/core/frontend/components/generic/ButtonMenu.vue b/aleksis/core/frontend/components/generic/ButtonMenu.vue index b105f84a8e1254f90bb1d78a2903c42f60cec29e..acc0990d050815c8bef8967aed93729c212f6f24 100644 --- a/aleksis/core/frontend/components/generic/ButtonMenu.vue +++ b/aleksis/core/frontend/components/generic/ButtonMenu.vue @@ -6,6 +6,7 @@ <v-icon center> {{ icon }} </v-icon> + <span v-if="textTranslationKey">{{ $t(textTranslationKey) }}</span> </v-btn> </slot> </template> @@ -25,6 +26,11 @@ export default { required: false, default: "mdi-dots-horizontal", }, + textTranslationKey: { + type: String, + required: false, + default: "", + }, }, }; </script> diff --git a/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue b/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..f4c167229bb53e444696522e2643e73540536e99 --- /dev/null +++ b/aleksis/core/frontend/components/generic/CopyToClipboardButton.vue @@ -0,0 +1,67 @@ +<template> + <v-tooltip bottom :open-on-hover="hover" v-model="tooltipModel"> + <template #activator="{ on, attrs }"> + <v-layout wrap v-on="on" v-bind="attrs"> + <v-btn fab x-small icon @click.stop="copyToClipboard(text)"> + <v-scroll-x-transition mode="out-in"> + <v-icon :key="clipboardIcon"> + {{ clipboardIcon }} + </v-icon> + </v-scroll-x-transition> + </v-btn> + </v-layout> + </template> + <span>{{ tooltipText }}</span> + </v-tooltip> +</template> + +<script> +export default { + name: "CopyToClipboardButton", + data() { + return { + copied: false, + tooltipModel: false, + hover: true, + }; + }, + props: { + text: { + type: String, + required: true, + }, + tooltipHelpText: { + type: String, + default: "", + }, + }, + computed: { + tooltipText() { + return this.copied + ? this.$t("actions.copied") + : this.tooltipHelpText + ? this.tooltipHelpText + : this.$t("actions.copy"); + }, + clipboardIcon() { + return this.copied + ? "mdi-clipboard-check-outline" + : "mdi-clipboard-outline"; + }, + }, + methods: { + copyToClipboard(text) { + navigator.clipboard.writeText(text); + this.tooltipModel = false; + setTimeout(() => { + this.tooltipModel = this.copied = true; + this.hover = false; + setTimeout(() => { + this.tooltipModel = this.copied = false; + this.hover = true; + }, 1000); + }, 100); + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue index b64769cc6d3dacb130197a3efcea516ef3d89c83..1c51c00661816fe3e244e6d511a0e70c747dbca6 100644 --- a/aleksis/core/frontend/components/notifications/NotificationList.vue +++ b/aleksis/core/frontend/components/notifications/NotificationList.vue @@ -83,7 +83,7 @@ export default { apollo: { myNotifications: { query: gqlMyNotifications, - pollInterval: 1000, + pollInterval: 30000, }, }, }; diff --git a/aleksis/core/frontend/index.js b/aleksis/core/frontend/index.js index 739c99eb1628165cac893789ac4ff17b2533cefc..e54375745f0941ca8799a0537ac9fdcda6da9122 100644 --- a/aleksis/core/frontend/index.js +++ b/aleksis/core/frontend/index.js @@ -36,9 +36,7 @@ import routerOpts from "./app/router.js"; import apolloOpts from "./app/apollo.js"; const i18n = new VueI18n({ - locale: Vue.$cookies.get("django_language") - ? Vue.$cookies.get("django_language") - : "en", + locale: Vue.$cookies.get("django_language") || navigator.language || "en", ...i18nOpts, }); const vuetify = new Vuetify({ @@ -72,6 +70,8 @@ const app = new Vue({ invalidation: false, snackbarItems: [], toolbarTitle: "AlekSIS®", + permissions: [], + permissionNames: [], }), computed: { matchedComponents() { @@ -91,6 +91,6 @@ const app = new Vue({ }); // Late setup for some plugins handed off to out ALeksisVue plugin -app.$loadAppMessages(); app.$loadVuetifyMessages(); +app.$loadAppMessages(); app.$setupNavigationGuards(); diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 28541da25604e98308d400d3c9bada9d921d467e..23ffc376f590f14b3aec8c17237926a34cb99d2c 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -81,6 +81,10 @@ "confirm_deletion_multiple": "Are you sure you want to delete these items?", "delete": "Delete", "edit": "Edit", + "close": "Close", + "select_all": "Select all", + "copy": "Copy", + "copied": "Copied", "save": "Save", "search": "Search", "stop_editing": "Stop editing", @@ -256,6 +260,16 @@ "snackbar_error_message": "There was an error retrieving the page data. Please try again.", "snackbar_success_message": "The operation has been finished successfully." }, + "calendar": { + "menu_title_overview": "Calendar", + "month": "Month", + "week": "Week", + "day": "Day", + "today": "Today", + "select": "Select calendars", + "ics_to_clipboard": "Copy link to calendar ICS to clipboard", + "cancelled": "Cancelled" + }, "status": { "changes": "You have unsaved changes.", "error": "There has been an error while saving the latest changes.", diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js new file mode 100644 index 0000000000000000000000000000000000000000..ef874b5c5600d487472ca73b957b4ed3fad801bc --- /dev/null +++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js @@ -0,0 +1,43 @@ +/** + * Mixin for use with adaptable components showing details for calendar feeds. + */ +const calendarFeedDetailsMixin = { + props: { + selectedElement: { + required: false, + default: null, + }, + selectedEvent: { + required: true, + type: Object, + }, + value: { type: Boolean, required: true }, + withoutTime: { + required: true, + type: Boolean, + default: false, + }, + withoutDescription: { + required: true, + type: Boolean, + default: false, + }, + withoutBadge: { + required: true, + type: Boolean, + default: false, + }, + }, + computed: { + model: { + get() { + return this.value; + }, + set(value) { + this.$emit("input", value); + }, + }, + }, +}; + +export default calendarFeedDetailsMixin; diff --git a/aleksis/core/frontend/mixins/calendarFeedEventBar.js b/aleksis/core/frontend/mixins/calendarFeedEventBar.js new file mode 100644 index 0000000000000000000000000000000000000000..5a0367c2b7590dce12f6eb5d85097e23bd1b1ced --- /dev/null +++ b/aleksis/core/frontend/mixins/calendarFeedEventBar.js @@ -0,0 +1,31 @@ +/** + * Mixin for use with adaptable components showing event bar for calendar feeds. + */ +const calendarFeedEventBarMixin = { + props: { + event: { + required: true, + type: Object, + }, + eventParsed: { + required: true, + type: Object, + }, + calendarType: { + required: true, + type: String, + }, + icon: { + required: false, + type: String, + default: "", + }, + withoutTime: { + required: true, + type: Boolean, + default: false, + }, + }, +}; + +export default calendarFeedEventBarMixin; diff --git a/aleksis/core/frontend/mixins/menus.js b/aleksis/core/frontend/mixins/menus.js index 9ba58bcaa84e72917e3057614fca6058df39af3a..60ce66b5b0d36efa77cc2b8944ce21e928a1f107 100644 --- a/aleksis/core/frontend/mixins/menus.js +++ b/aleksis/core/frontend/mixins/menus.js @@ -1,15 +1,17 @@ import gqlCustomMenu from "../components/app/customMenu.graphql"; +import permissionsMixin from "./permissions.js"; + /** * Vue mixin containing menu generation code. * * Only used by main App component, but factored out for readability. */ const menusMixin = { + mixins: [permissionsMixin], data() { return { footerMenu: null, - permissionNames: [], sideNavMenu: null, accountMenu: null, }; @@ -35,8 +37,7 @@ const menusMixin = { } } - this.permissionNames = permArray; - this.$apollo.queries.whoAmI.refetch(); + this.addPermissions(permArray); }, buildMenu(routes, menuKey) { let menu = {}; @@ -99,14 +100,6 @@ const menusMixin = { return Object.values(menu); }, - checkPermission(permissionName) { - return ( - this.whoAmI && - this.whoAmI.permissions && - this.whoAmI.permissions.find((p) => p.name === permissionName) && - this.whoAmI.permissions.find((p) => p.name === permissionName).result - ); - }, checkValidators(validators) { for (const validator of validators) { if (!validator(this.whoAmI)) { @@ -118,14 +111,9 @@ const menusMixin = { buildMenus() { this.accountMenu = this.buildMenu( this.$router.getRoutes(), - "inAccountMenu", - this.whoAmI ? this.whoAmI.permissions : [] - ); - this.sideNavMenu = this.buildMenu( - this.$router.getRoutes(), - "inMenu", - this.whoAmI ? this.whoAmI.permissions : [] + "inAccountMenu" ); + this.sideNavMenu = this.buildMenu(this.$router.getRoutes(), "inMenu"); }, }, apollo: { diff --git a/aleksis/core/frontend/mixins/permissions.js b/aleksis/core/frontend/mixins/permissions.js new file mode 100644 index 0000000000000000000000000000000000000000..9d5a00f51a747e06d0d38461351d9bc63a1dc8ac --- /dev/null +++ b/aleksis/core/frontend/mixins/permissions.js @@ -0,0 +1,28 @@ +/** + * Vue mixin containing permission checking code. + */ + +const permissionsMixin = { + methods: { + checkPermission(permissionName) { + return ( + this.$root.permissions && + this.$root.permissions.find((p) => p.name === permissionName) && + this.$root.permissions.find((p) => p.name === permissionName).result + ); + }, + addPermissions(newPermissionNames) { + const keepPermissionNames = this.$root.permissionNames.filter( + (oldPermName) => + !newPermissionNames.find((newPermName) => newPermName === oldPermName) + ); + + this.$root.permissionNames = [ + ...keepPermissionNames, + ...newPermissionNames, + ]; + }, + }, +}; + +export default permissionsMixin; diff --git a/aleksis/core/frontend/mixins/routes.js b/aleksis/core/frontend/mixins/routes.js index 9adbdafc7426fcc19174f91266b978471f3a38e9..e458b23a6f65d22f9682178980ab8e15fe1e74d5 100644 --- a/aleksis/core/frontend/mixins/routes.js +++ b/aleksis/core/frontend/mixins/routes.js @@ -14,7 +14,7 @@ const routesMixin = { apollo: { dynamicRoutes: { query: gqlDynamicRoutes, - pollInterval: 10000, + pollInterval: 30000, }, }, watch: { diff --git a/aleksis/core/frontend/plugins/aleksis.js b/aleksis/core/frontend/plugins/aleksis.js index b69eb2ebe36560c7f18bcd7c150d94fd7539a3b3..09c04330a58adede55fa2198bc2c637e0707a829 100644 --- a/aleksis/core/frontend/plugins/aleksis.js +++ b/aleksis/core/frontend/plugins/aleksis.js @@ -147,10 +147,10 @@ AleksisVue.install = function (Vue) { * Load vuetifys built-in translations */ Vue.prototype.$loadVuetifyMessages = function () { - for(const [locale, messages] of Object.entries(langs)) { - this.$i18n.mergeLocaleMessage(locale, {$vuetify: messages}) + for (const [locale, messages] of Object.entries(langs)) { + this.$i18n.mergeLocaleMessage(locale, { $vuetify: messages }); } - } + }; /** * Invalidate state and force reload from server. diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 5024ff93520b586f4dc198cd9b47004fac0e2cf9..a1d81600660398926d3ba843be0a5000870f4359 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -8,7 +8,7 @@ // aleksisAppImporter is a virtual module defined in Vite config import { appObjects } from "aleksisAppImporter"; -import { notLoggedInValidator } from "./routeValidators"; +import { hasPersonValidator, notLoggedInValidator } from "./routeValidators"; const routes = [ { @@ -54,6 +54,7 @@ const routes = [ icon: "mdi-key-outline", titleKey: "accounts.invitation.accept_invitation.menu_title", validators: [notLoggedInValidator], + permission: "core.invite_enabled", }, }, { @@ -949,14 +950,6 @@ const routes = [ invalidate: "leave", }, }, - { - path: "/invitations/code/enter", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.enter_invitation_code", - }, { path: "/invitations/code/generate", component: () => import("./components/LegacyBaseTemplate.vue"), @@ -1056,6 +1049,17 @@ const routes = [ }, name: "invitations.accept_invite", }, + { + path: "/calendar/overview", + component: () => import("./components/calendar/CalendarOverview.vue"), + name: "core.calendar_overview", + meta: { + inMenu: true, + icon: "mdi-calendar", + titleKey: "calendar.menu_title_overview", + validators: [hasPersonValidator], + }, + }, ]; // This imports all known AlekSIS app entrypoints diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index 7a549775f1730d57f83e6c3486644fd2c3bdda65..66cac5f59cb9a328aa36be23f36f5dc481b8e1f1 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -8,7 +8,7 @@ from django.db.models.manager import Manager from calendarweek import CalendarWeek from django_cte import CTEManager, CTEQuerySet -from polymorphic.managers import PolymorphicManager +from polymorphic.managers import PolymorphicManager, PolymorphicQuerySet class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager): @@ -123,5 +123,16 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager): return super().get_queryset().filter(widget_id__in=dashboard_widget_pks) -class PolymorphicCurrentSiteManager(_CurrentSiteManager, PolymorphicManager): +class PolymorphicCurrentSiteManager(CurrentSiteManagerWithoutMigrations, PolymorphicManager): """Default manager for extensible, polymorphic models.""" + + +class HolidayQuerySet(DateRangeQuerySetMixin, PolymorphicQuerySet): + """QuerySet with custom query methods for holidays.""" + + def get_all_days(self) -> list[date]: + """Get all days included in the selected holidays.""" + holiday_days = [] + for holiday in self: + holiday_days += list(holiday.get_days()) + return holiday_days diff --git a/aleksis/core/migrations/0049_calendarevent.py b/aleksis/core/migrations/0049_calendarevent.py new file mode 100644 index 0000000000000000000000000000000000000000..d2f54fc4a353c982e5e5da6b0a879cf484d4dc71 --- /dev/null +++ b/aleksis/core/migrations/0049_calendarevent.py @@ -0,0 +1,76 @@ +# Generated by Django 4.1.5 on 2023-01-29 13:46 + +import aleksis.core.managers +import aleksis.core.mixins +from django.db import migrations, models +import django.db.models.deletion +import recurrence.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("contenttypes", "0002_remove_content_type_name"), + ("core", "0048_delete_personalicalurl"), + ] + + operations = [ + migrations.CreateModel( + name="CalendarEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("extended_data", models.JSONField(default=dict, editable=False)), + ("start", models.DateTimeField(verbose_name="Start date and time")), + ("end", models.DateTimeField(verbose_name="End date and time")), + ( + "recurrences", + recurrence.fields.RecurrenceField( + blank=True, null=True, verbose_name="Recurrences" + ), + ), + ( + "ammends", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="core.calendarevent", + verbose_name="Ammended base event", + ), + ), + ( + "polymorphic_ctype", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ( + "site", + models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="sites.site", + ), + ), + ], + options={ + "verbose_name": "Calendar Event", + "verbose_name_plural": "Calendar Events", + }, + bases=(aleksis.core.mixins.CalendarEventMixin, models.Model), + managers=[ + ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + ] diff --git a/aleksis/core/migrations/0050_fix_amends.py b/aleksis/core/migrations/0050_fix_amends.py new file mode 100644 index 0000000000000000000000000000000000000000..09ea72fb0f44577aec4284343420dc7253e478cb --- /dev/null +++ b/aleksis/core/migrations/0050_fix_amends.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.7 on 2023-03-26 10:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0049_calendarevent"), + ] + + operations = [ + migrations.RenameField( + model_name="calendarevent", + old_name="ammends", + new_name="amends", + ), + ] diff --git a/aleksis/core/migrations/0051_calendarevent_dates.py b/aleksis/core/migrations/0051_calendarevent_dates.py new file mode 100644 index 0000000000000000000000000000000000000000..e4b0bc3e763a0b00447f733d0c26f8c65373119f --- /dev/null +++ b/aleksis/core/migrations/0051_calendarevent_dates.py @@ -0,0 +1,92 @@ +# Generated by Django 4.1.8 on 2023-04-08 15:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0050_fix_amends"), + ] + + operations = [ + migrations.AlterModelOptions( + name="calendarevent", + options={ + "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"], + "verbose_name": "Calendar Event", + "verbose_name_plural": "Calendar Events", + }, + ), + migrations.RenameField( + model_name="calendarevent", + old_name="end", + new_name="datetime_end", + ), + migrations.RenameField( + model_name="calendarevent", + old_name="start", + new_name="datetime_start", + ), + migrations.AddField( + model_name="calendarevent", + name="date_end", + field=models.DateField(blank=True, null=True, verbose_name="End date"), + ), + migrations.AddField( + model_name="calendarevent", + name="date_start", + field=models.DateField(blank=True, null=True, verbose_name="Start date"), + ), + migrations.AlterField( + model_name="calendarevent", + name="amends", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="core.calendarevent", + verbose_name="Amended base event", + ), + ), + migrations.AlterField( + model_name="calendarevent", + name="datetime_end", + field=models.DateTimeField(blank=True, null=True, verbose_name="End date and time"), + ), + migrations.AlterField( + model_name="calendarevent", + name="datetime_start", + field=models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"), + ), + migrations.AlterField( + model_name="calendarevent", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AddConstraint( + model_name="calendarevent", + constraint=models.CheckConstraint( + check=models.Q( + ("date_start__isnull", True), ("datetime_start__isnull", True), _negated=True + ), + name="datetime_start_or_date_start", + ), + ), + migrations.AddConstraint( + model_name="calendarevent", + constraint=models.CheckConstraint( + check=models.Q( + ("date_end__isnull", True), ("datetime_end__isnull", True), _negated=True + ), + name="datetime_end_or_date_end", + ), + ), + ] diff --git a/aleksis/core/migrations/0052_holiday.py b/aleksis/core/migrations/0052_holiday.py new file mode 100644 index 0000000000000000000000000000000000000000..ed2e75ba011261884c26e9e695f85595d0b63e91 --- /dev/null +++ b/aleksis/core/migrations/0052_holiday.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.8 on 2023-04-09 14:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0051_calendarevent_dates"), + ] + + operations = [ + migrations.CreateModel( + name="Holiday", + fields=[ + ( + "calendarevent_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.calendarevent", + ), + ), + ("holiday_name", models.CharField(max_length=255, verbose_name="Name")), + ], + options={ + "verbose_name": "Holiday", + "verbose_name_plural": "Holidays", + }, + bases=("core.calendarevent",), + ), + ] diff --git a/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py b/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..452e18e2a9bed5be3c42f19eb313b9a54df5a27d --- /dev/null +++ b/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py @@ -0,0 +1,223 @@ +# Generated by Django 4.1.8 on 2023-05-01 17:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0052_holiday"), + ] + + operations = [ + migrations.AlterModelManagers( + name="calendarevent", + managers=[], + ), + migrations.AlterField( + model_name="activity", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="additionalfield", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="announcement", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="announcementrecipient", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="custommenu", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="custommenuitem", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="dashboardwidgetorder", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="datacheckresult", + name="data_check", + field=models.CharField( + choices=[ + ( + "broken_dashboard_widgets", + "Ensure that there are no broken DashboardWidgets.", + ), + ( + "field_validation_custommenuitem_icon", + "Validate field icon of model core.CustomMenuItem.", + ), + ], + max_length=255, + verbose_name="Related data check task", + ), + ), + migrations.AlterField( + model_name="datacheckresult", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="group", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="grouptype", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="notification", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="pdffile", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="person", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="persongroupthrough", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="room", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="schoolterm", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + migrations.AlterField( + model_name="taskuserassignment", + name="site", + field=models.ForeignKey( + default=1, + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="sites.site", + ), + ), + ] diff --git a/aleksis/core/migrations/0054_calendarevent_timezone.py b/aleksis/core/migrations/0054_calendarevent_timezone.py new file mode 100644 index 0000000000000000000000000000000000000000..f2a98b3d712c9e3dcc2410ff398902370d8e8b30 --- /dev/null +++ b/aleksis/core/migrations/0054_calendarevent_timezone.py @@ -0,0 +1,51 @@ +# Generated by Django 4.1.9 on 2023-05-29 11:00 + +from django.db import migrations, models +import django.db.models.deletion +import timezone_field.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0053_alter_calendarevent_managers_alter_activity_site_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="calendarevent", + name="timezone", + field=timezone_field.fields.TimeZoneField( + blank=True, null=True, verbose_name="Timezone" + ), + ), + migrations.AlterField( + model_name="calendarevent", + name="amends", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="amended_by", + to="core.calendarevent", + verbose_name="Amended base event", + ), + ), + migrations.AddConstraint( + model_name="calendarevent", + constraint=models.CheckConstraint( + check=models.Q( + ("datetime_start__isnull", False), ("timezone__isnull", True), _negated=True + ), + name="timezone_if_datetime_start", + ), + ), + migrations.AddConstraint( + model_name="calendarevent", + constraint=models.CheckConstraint( + check=models.Q( + ("datetime_end__isnull", False), ("timezone__isnull", True), _negated=True + ), + name="timezone_if_datetime_end", + ), + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index e734b40a8f7cf55b86e1f496cfb8e8cfe0099cfb..43bed808ee3f36b98f2745a5bab329f6ece97508 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from typing import Any, Callable, ClassVar, List, Optional, Union +from typing import Any, Callable, ClassVar, Iterable, List, Optional, Union from django.conf import settings from django.contrib import messages @@ -15,17 +15,20 @@ from django.db.models import JSONField, QuerySet from django.db.models.fields import CharField, TextField from django.forms.forms import BaseForm from django.forms.models import ModelForm, ModelFormMetaclass -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.utils.functional import classproperty, lazy from django.utils.translation import gettext as _ from django.views.generic import CreateView, UpdateView from django.views.generic.edit import DeleteView, ModelFormMixin +import recurring_ical_events import reversion +from django_ical.feedgenerator import ITEM_ELEMENT_FIELD_MAP from dynamic_preferences.settings import preferences_settings from dynamic_preferences.types import FilePreference from guardian.admin import GuardedModelAdmin from guardian.core import ObjectPermissionChecker +from icalendar import Calendar from jsonstore.fields import IntegerField, JSONFieldMixin from material.base import Layout, LayoutNode from polymorphic.base import PolymorphicModelBase @@ -39,6 +42,8 @@ from aleksis.core.managers import ( SchoolTermRelatedQuerySet, ) +from .util.core_helpers import ExtendedICal20Feed + class _ExtensibleModelBase(models.base.ModelBase): """Ensure predefined behaviour on model creation. @@ -548,7 +553,7 @@ class PublicFilePreferenceMixin(FilePreference): class RegistryObject: """Generic registry to allow registration of subclasses over all apps.""" - _registry: ClassVar[Optional[dict[str, "RegistryObject"]]] = None + _registry: ClassVar[Optional[dict[str, type["RegistryObject"]]]] = None name: ClassVar[str] = "" def __init_subclass__(cls): @@ -560,18 +565,160 @@ class RegistryObject: cls._register() @classmethod - def _register(cls): + def _register(cls: type["RegistryObject"]): if cls.name and cls.name not in cls._registry: cls._registry[cls.name] = cls @classproperty - def registered_objects_dict(cls): + def registered_objects_dict(cls) -> dict[str, type["RegistryObject"]]: + """Get dict of registered objects.""" return cls._registry @classproperty - def registered_objects_list(cls): + def registered_objects_list(cls) -> list[type["RegistryObject"]]: + """Get list of registered objects.""" return list(cls._registry.values()) @classmethod - def get_object_by_name(cls, name): - cls.registered_objects_dict.get(name) + def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]: + """Get registered object by name.""" + return cls.registered_objects_dict.get(name) + + +class CalendarEventMixin(RegistryObject): + """Mixin for calendar feeds. + + This mixin can be used to create calendar feeds for objects. It can be used + by adding it to a model or another object. The basic attributes of the calendar + can be set by either setting the attributes of the class or by implementing + the corresponding class methods. Please notice that the class methods are + overriding the attributes. The following attributes are mandatory: + + - name: Unique name for the calendar feed + - verbose_name: Shown name of the feed + + The respective class methods have a `get_` prefix and are called without any arguments. + There are also some more attributes. Please refer to the class signature for more + information. + + The list of objects used to create the calendar feed have to be provided by + the method `get_objects` class method. It's mandatory to implement this method. + + To provide the data for the events, a certain set of class methods can be implemented. + The following iCal attributes are supported: + + guid, title, description, link, class, created, updateddate, start_datetime, end_datetime, + location, geolocation, transparency, organizer, attendee, rrule, rdate, exdate, valarm, status + + To implement a method for a certain attribute, the name of the method has to be + `value_<your_attribute>`. For example, to implement the `title` attribute, the + method `value_title` has to be implemented. The method has to return the value + for the attribute. The method is called with the reference object as argument. + """ + + name: str = "" # Unique name for the calendar feed + verbose_name: str = "" # Shown name of the feed + link: str = "" # Link for the feed, optional + description: str = "" # Description of the feed, optional + color: str = "#222222" # Color of the feed, optional + + @classmethod + def get_verbose_name(cls, request) -> str: + """Return the verbose name of the calendar feed.""" + return cls.verbose_name + + @classmethod + def get_link(cls, request) -> str: + """Return the link of the calendar feed.""" + return cls.link + + @classmethod + def get_description(cls, request) -> str: + """Return the description of the calendar feed.""" + return cls.description + + @classmethod + def get_language(cls, request) -> str: + """Return the language of the calendar feed.""" + return "en" # FIXME + + @classmethod + def get_color(cls, request) -> str: + """Return the color of the calendar feed.""" + return cls.color + + @classmethod + def create_event(cls, reference_object, feed: ExtendedICal20Feed, request) -> dict[str, Any]: + """Create an event for the given reference object and add it to the feed.""" + values = {} + for field in cls.get_event_field_names(): + field_value = cls.get_event_field_value(reference_object, field, request) + if field_value is not None: + values[field] = field_value + feed.add_item(**values) + return values + + @classmethod + def start_feed(cls, request) -> ExtendedICal20Feed: + """Start the feed and return it.""" + feed = ExtendedICal20Feed( + title=cls.get_verbose_name(request), + link=cls.get_link(request), + description=cls.get_description(request), + language=cls.get_language(request), + color=cls.get_color(request), + ) + return feed + + @classmethod + def get_objects(cls, request: HttpRequest) -> Iterable: + """Return the objects to create the calendar feed for.""" + raise NotImplementedError + + @classmethod + def create_feed(cls, request: HttpRequest) -> ExtendedICal20Feed: + """Create the calendar feed with all events.""" + feed = cls.start_feed(request) + + for reference_object in cls.get_objects(request): + cls.create_event(reference_object, feed, request) + + return feed + + @classmethod + def get_calendar_object(cls, request: HttpRequest) -> Calendar: + """Return the calendar object.""" + feed = cls.create_feed(request) + return feed.get_calendar_object() + + @classmethod + def get_single_events(cls, request: HttpRequest, start=None, end=None): + """Get single events for this calendar feed.""" + feed = cls.create_feed(request) + return feed.get_single_events(start, end) + + @classmethod + def get_event_field_names(cls) -> list[str]: + """Return the names of the fields to be used for the feed.""" + return [field_map[0] for field_map in ITEM_ELEMENT_FIELD_MAP] + + @classmethod + def get_event_field_value(cls, reference_object, field_name: str, request): + """Return the value for the given field name.""" + method_name = f"value_{field_name}" + if hasattr(cls, method_name) and callable(getattr(cls, method_name)): + return getattr(cls, method_name)(reference_object, request) + return None + + @classmethod + def value_link(cls, reference_object, request) -> str: + return "" + + @classmethod + def value_color(cls, reference_object, request) -> str: + return cls.get_color(request) + + @classproperty + def valid_feeds(cls): + """Return a list of valid feeds.""" + return [feed for feed in cls.registered_objects_list if feed.name != feed.__name__] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index d90790427fb3c22531ec0cda50e9c48eb615e196..3f6f6d915a4570855317de09939c980b25069949 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -3,7 +3,7 @@ import base64 import hmac import uuid from datetime import date, datetime, timedelta -from typing import Any, Iterable, List, Optional, Sequence, Union +from typing import Any, Iterable, Iterator, List, Optional, Sequence, Union from urllib.parse import urljoin, urlparse from django.conf import settings @@ -23,6 +23,7 @@ from django.dispatch import receiver from django.forms.widgets import Media from django.urls import reverse from django.utils import timezone +from django.utils.formats import date_format from django.utils.functional import classproperty from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -31,12 +32,17 @@ import customidenticon import jsonstore from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize +from calendarweek import CalendarWeek from celery.result import AsyncResult from celery_progress.backend import Progress from ckeditor.fields import RichTextField from django_celery_results.models import TaskResult from django_cte import CTEQuerySet, With +from django_ical.utils import build_rrule_from_recurrences_rrule, build_rrule_from_text from dynamic_preferences.models import PerInstancePreferenceModel +from guardian.shortcuts import get_objects_for_user +from icalendar import vCalAddress, vText +from icalendar.prop import vRecur from invitations import signals from invitations.adapters import get_invitations_adapter from invitations.base_invitation import AbstractBaseInvitation @@ -52,6 +58,8 @@ from oauth2_provider.models import ( ) from phonenumber_field.modelfields import PhoneNumberField from polymorphic.models import PolymorphicModel +from recurrence.fields import RecurrenceField +from timezone_field import TimeZoneField from aleksis.core.data_checks import ( BrokenDashboardWidgetDataCheck, @@ -63,12 +71,16 @@ from .managers import ( CurrentSiteManagerWithoutMigrations, GroupManager, GroupQuerySet, + HolidayQuerySet, InstalledWidgetsDashboardWidgetOrderManager, + PolymorphicCurrentSiteManager, SchoolTermQuerySet, UninstallRenitentPolymorphicManager, ) from .mixins import ( + CalendarEventMixin, ExtensibleModel, + ExtensiblePolymorphicModel, GlobalPermissionModel, PureDjangoModel, RegistryObject, @@ -365,6 +377,16 @@ class Person(ExtensibleModel): else: return self.identicon_url + def get_vcal_address(self, role: str = "REQ-PARTICIPANT") -> Optional[vCalAddress]: + """Return a vCalAddress object for this person.""" + if not self.email: + # vCalAddress requires an email address + return None + vcal = vCalAddress(f"MAILTO:{self.email}") + vcal.params["cn"] = vText(self.full_name) + vcal.params["ROLE"] = vText(role) + return vcal + def save(self, *args, **kwargs): # Determine all fields that were changed since last load changed = self.user_info_tracker.changed() @@ -1484,3 +1506,266 @@ class Room(ExtensibleModel): fields=["site_id", "short_name"], name="unique_room_short_name_per_site" ), ] + + +class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel): + """A planned event in a calendar. + + To make use of this model, you need to inherit from this model. + Every subclass of this model represents a certain calendar (feed). + It therefore needs to set the basic attributes of the calendar like + described in the documentation of `CalendarEventMixin`. + + Furthermore, every `value_*` method from `CalendarEventMixin` + can be implemented to provide additional data (either static or dynamic). + Some like start and end date are pre-implemented in this model. Others, like + `value_title` need to be implemented in the subclass. Some methods are + also optional, like `value_location` or `value_description`. + Please refer to the documentation of `CalendarEventMixin` for more information. + """ + + datetime_start = models.DateTimeField( + verbose_name=_("Start date and time"), null=True, blank=True + ) + datetime_end = models.DateTimeField(verbose_name=_("End date and time"), null=True, blank=True) + timezone = TimeZoneField(verbose_name=_("Timezone"), null=True, blank=True) + date_start = models.DateField(verbose_name=_("Start date"), null=True, blank=True) + date_end = models.DateField(verbose_name=_("End date"), null=True, blank=True) + recurrences = RecurrenceField(verbose_name=_("Recurrences"), null=True, blank=True) + amends = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + verbose_name=_("Amended base event"), + related_name="amended_by", + ) + + def provide_list_in_timezone(self, seq): + """Provide a list of datetimes in the saved timezone.""" + return [dt.astimezone(self.timezone) if isinstance(dt, datetime) else dt for dt in seq] + + @classmethod + def value_title(cls, reference_object: "CalendarEvent", request) -> str: + """Return the title of the calendar event.""" + raise NotImplementedError() + + @classmethod + def value_start_datetime( + cls, reference_object: "CalendarEvent", request + ) -> Union[datetime, date]: + """Return the start datetime of the calendar event.""" + if reference_object.datetime_start: + return reference_object.datetime_start.astimezone(reference_object.timezone) + return reference_object.date_start + + @classmethod + def value_end_datetime( + cls, reference_object: "CalendarEvent", request + ) -> Union[datetime, date]: + """Return the end datetime of the calendar event.""" + if reference_object.datetime_end: + return reference_object.datetime_end.astimezone(reference_object.timezone) + return reference_object.date_end + timedelta(days=1) + + @classmethod + def value_rrule(cls, reference_object: "CalendarEvent", request) -> Optional[vRecur]: + """Return the rrule of the calendar event.""" + if not reference_object.recurrences or not reference_object.recurrences.rrules: + return None + # iCal only supports one RRULE per event as per RFC 5545 (change to older RFC 2445) + return build_rrule_from_recurrences_rrule(reference_object.recurrences.rrules[0]) + + @classmethod + def value_rdate(cls, reference_object: "CalendarEvent", request) -> Optional[list[datetime]]: + """Return the rdate of the calendar event.""" + if not reference_object.recurrences: + return None + return reference_object.provide_list_in_timezone(reference_object.recurrences.rdates) + + @classmethod + def value_exrule(cls, reference_object: "CalendarEvent", request) -> Optional[vRecur]: + """Return the exrule of the calendar event.""" + if not reference_object.recurrences or not reference_object.recurrences.exrules: + return None + return [build_rrule_from_recurrences_rrule(r) for r in reference_object.recurrences.exrules] + + @classmethod + def value_exdate(cls, reference_object: "CalendarEvent", request) -> Optional[list[datetime]]: + """Return the exdate of the calendar event.""" + if not reference_object.recurrences: + return None + # return reference_object.recurrences.exdates + return reference_object.provide_list_in_timezone(reference_object.recurrences.exdates) + + @classmethod + def value_unique_id(cls, reference_object: "CalendarEvent", request) -> str: + """Return an unique identifier for an event.""" + if reference_object.amends: + return cls.value_unique_id(reference_object.amends, request) + return f"{cls.name}-{reference_object.id}" + + @classmethod + def value_recurrence_id( + cls, reference_object: "CalendarEvent", request + ) -> Optional[Union[datetime, date]]: + """Return the recurrence id of the calendar event.""" + if reference_object.amends: + return reference_object.amends.value_start_datetime(reference_object, request) + return None + + @classmethod + def value_color(cls, reference_object: "CalendarEvent", request) -> str: + """Return the color of the calendar.""" + return cls.get_color(request) + + @classmethod + def get_objects(cls, request) -> Iterable: + """Return all objects that should be included in the calendar.""" + return cls.objects.instance_of(cls) + + def save(self, *args, **kwargs): + if ( + self.datetime_start + and self.datetime_end + and self.datetime_start.tzinfo != self.datetime_end.tzinfo + ): + self.datetime_end = self.datetime_end.astimezone(self.datetime_start.tzinfo) + if self.datetime_start and self.datetime_end: + self.timezone = self.datetime_start.tzinfo + super().save(*args, **kwargs) + + class Meta: + verbose_name = _("Calendar Event") + verbose_name_plural = _("Calendar Events") + constraints = [ + models.CheckConstraint( + check=~Q(datetime_start__isnull=True, date_start__isnull=True), + name="datetime_start_or_date_start", + ), + models.CheckConstraint( + check=~Q(datetime_end__isnull=True, date_end__isnull=True), + name="datetime_end_or_date_end", + ), + models.CheckConstraint( + check=~Q(datetime_start__isnull=False, timezone__isnull=True), + name="timezone_if_datetime_start", + ), + models.CheckConstraint( + check=~Q(datetime_end__isnull=False, timezone__isnull=True), + name="timezone_if_datetime_end", + ), + ] + ordering = ["datetime_start", "date_start", "datetime_end", "date_end"] + + +class BirthdayEvent(CalendarEventMixin): + """A calendar feed with all birthdays.""" + + name = "birthdays" + verbose_name = _("Birthdays") + + @classmethod + def value_title(cls, reference_object: Person, request) -> str: + return _("{}'s birthday").format(reference_object.addressing_name) + + @classmethod + def value_description(cls, reference_object: Person, request) -> str: + return ("{name} was born on {birthday}.").format( + name=reference_object.addressing_name, + birthday=date_format(reference_object.date_of_birth), + ) + + @classmethod + def value_start_datetime(cls, reference_object: Person, request) -> date: + return reference_object.date_of_birth + + @classmethod + def value_rrule(cls, reference_object: Person, request) -> vRecur: + return build_rrule_from_text("FREQ=YEARLY") + + @classmethod + def value_unique_id(cls, reference_object: Person, request) -> str: + return f"birthday-{reference_object.id}" + + @classmethod + def get_color(cls, request) -> str: + return get_site_preferences()["calendar__birthday_color"] + + @classmethod + def get_objects(cls, request) -> QuerySet: + qs = Person.objects.filter(date_of_birth__isnull=False) + qs = qs.filter( + Q(pk=request.user.person.pk) + | Q(pk__in=get_objects_for_user(request.user, "core.view_personal_details", qs)) + ) + return qs + + +class Holiday(CalendarEvent): + """Holiday model for keeping track of school holidays.""" + + name = "holidays" + verbose_name = _("Holidays") + + @classmethod + def value_title(cls, reference_object: "Holiday", request) -> str: + return reference_object.holiday_name + + @classmethod + def value_description(cls, reference_object: "Holiday", request) -> str: + return "" + + @classmethod + def get_color(cls, request) -> str: + return get_site_preferences()["calendar__holiday_color"] + + objects = PolymorphicCurrentSiteManager.from_queryset(HolidayQuerySet)() + + holiday_name = models.CharField(verbose_name=_("Name"), max_length=255) + + def get_days(self) -> Iterator[date]: + """Get all days included in the holiday.""" + delta = self.date_end - self.date_start + for i in range(delta.days + 1): + yield self.date_start + timedelta(days=i) + + @classmethod + def in_week(cls, week: CalendarWeek) -> dict[int, Optional["Holiday"]]: + """Get the holidays that are active in a given week.""" + per_weekday = {} + holidays = Holiday.objects.in_week(week) + + for weekday in range(0, 7): + holiday_date = week[weekday] + filtered_holidays = list( + filter( + lambda h: holiday_date >= h.date_start and holiday_date <= h.date_end, + holidays, + ) + ) + if filtered_holidays: + per_weekday[weekday] = filtered_holidays[0] + + return per_weekday + + @classmethod + def get_ex_dates(cls, datetime_start, datetime_end, recurrence): + """Get the dates to exclude for holidays.""" + recurrence.dtstart = recurrence.dtstart.astimezone(timezone.get_current_timezone()) + holiday_dates = [ + h["DTSTART"].dt for h in Holiday.get_single_events(datetime_start, datetime_end) + ] + exdates = [ + h.astimezone(timezone.utc) + for h in recurrence.occurrences() + if h.date() in holiday_dates + ] + return exdates + + def __str__(self) -> str: + return self.holiday_name + + class Meta: + verbose_name = _("Holiday") + verbose_name_plural = _("Holidays") diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 7d10c2ae5c4a7b2538ad907c227033ea6a0ef0f2..4c9758e16e3722c8775e50a11004c533366b3f05 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -30,6 +30,7 @@ footer = Section("footer", verbose_name=_("Footer")) account = Section("account", verbose_name=_("Accounts")) auth = Section("auth", verbose_name=_("Authentication")) internationalisation = Section("internationalisation", verbose_name=_("Internationalisation")) +calendar = Section("calendar", verbose_name=_("Calendar")) @site_preferences_registry.register @@ -477,3 +478,27 @@ class AutoUpdatingDashboardSite(BooleanPreference): name = "automatically_update_dashboard_site" default = True verbose_name = _("Automatically update the dashboard and its widgets sitewide") + + +@site_preferences_registry.register +class BirthdayFeedColor(StringPreference): + """Color for the birthdays calendar feed.""" + + section = calendar + name = "birthday_color" + default = "#0d5eaf" + verbose_name = _("Birthday calendar feed color") + widget = ColorWidget + required = True + + +@site_preferences_registry.register +class HolidayFeedColor(StringPreference): + """Color for the holidays calendar feed.""" + + section = calendar + name = "holiday_color" + default = "#0d5eaf" + verbose_name = _("Holiday calendar feed color") + widget = ColorWidget + required = True diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 6d77fcd70fb32712c4358a9bcec5a03e306140d5..ae2f12129cd8193cb0f5fba9887a6a45c2c92785 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -409,3 +409,6 @@ rules.add_perm("core.test_pdf_rule", test_pdf_generation_predicate) view_progress_predicate = has_person & is_own_celery_task rules.add_perm("core.view_progress_rule", view_progress_predicate) + +view_calendar_feed_predicate = has_person +rules.add_perm("core.view_calendar_feed_rule", view_calendar_feed_predicate) diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index c5d5ddd502a2e8ebeb39c48288b94b05989c8d09..ecbd78eb930b83bd7fa6ba493e37ee242fb37fc9 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -10,6 +10,7 @@ from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex from .base import FilterOrderList +from ..mixins import CalendarEventMixin from ..models import ( CustomMenu, DynamicRoute, @@ -24,6 +25,7 @@ from ..models import ( ) from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person +from .calendar import CalendarType from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType from .dynamic_routes import DynamicRouteType @@ -83,6 +85,10 @@ class Query(graphene.ObjectType): oauth_access_tokens = graphene.List(OAuthAccessTokenType) + calendar_feeds = graphene.List(CalendarType) + + calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String)) + rooms = FilterOrderList(RoomType) room_by_id = graphene.Field(RoomType, id=graphene.ID()) @@ -194,6 +200,12 @@ class Query(graphene.ObjectType): def resolve_oauth_access_tokens(root, info, **kwargs): return OAuthAccessToken.objects.filter(user=info.context.user) + def resolve_calendar_feeds(root, info, **kwargs): + return CalendarEventMixin.valid_feeds + + def resolve_calendar_feeds_by_names(root, info, names, **kwargs): + return [CalendarEventMixin.get_object_by_name(name) for name in names] + @staticmethod def resolve_room_by_id(root, info, **kwargs): pk = kwargs.get("id") diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..4414249bc16259468c67256ee356732028cdaf7e --- /dev/null +++ b/aleksis/core/schema/calendar.py @@ -0,0 +1,77 @@ +from datetime import datetime + +from django.urls import reverse + +import graphene +from graphene import ObjectType + + +class CalendarEventType(ObjectType): + name = graphene.String() + description = graphene.String() + start = graphene.String() + end = graphene.String() + color = graphene.String() + uid = graphene.String() + all_day = graphene.Boolean() + status = graphene.String() + + def resolve_name(root, info, **kwargs): + return root["SUMMARY"] + + def resolve_description(root, info, **kwargs): + return root["DESCRIPTION"] + + def resolve_start(root, info, **kwargs): + return root["DTSTART"].dt + + def resolve_end(root, info, **kwargs): + return root["DTEND"].dt + + def resolve_color(root, info, **kwargs): + return root["COLOR"] + + def resolve_uid(root, info, **kwargs): + return root["UID"] + + def resolve_all_day(root, info, **kwargs): + return not isinstance(root["DTSTART"].dt, datetime) + + def resolve_status(root, info, **kwargs): + return root.get("STATUS", "") + + +class CalendarFeedType(ObjectType): + events = graphene.List( + CalendarEventType, + start=graphene.Date(required=False), + end=graphene.Date(required=False), + ) + + def resolve_events(root, info, start=None, end=None, **kwargs): + return root.get_single_events(start, end) + + +class CalendarType(ObjectType): + name = graphene.String(required=True) + verbose_name = graphene.String(required=True) + description = graphene.String() + feed = graphene.Field(CalendarFeedType) + color = graphene.String() + + url = graphene.String() + + def resolve_verbose_name(root, info, **kwargs): + return root.get_verbose_name(info.context) + + def resolve_description(root, info, **kwargs): + return root.get_description(info.context) + + def resolve_feed(root, info, **kwargs): + return root.create_feed(info.context) + + def resolve_url(root, info, **kwargs): + return info.context.build_absolute_uri(reverse("calendar_feed", args=[root.name])) + + def resolve_color(root, info, **kwargs): + return root.get_color(info.context) diff --git a/aleksis/core/schema/system_properties.py b/aleksis/core/schema/system_properties.py index 1546512fc4e488faa01a93d87510249577c63388..6d6e50d5958601db24eca6e61d959482611cf5af 100644 --- a/aleksis/core/schema/system_properties.py +++ b/aleksis/core/schema/system_properties.py @@ -20,6 +20,7 @@ class LanguageType(graphene.ObjectType): class SystemPropertiesType(graphene.ObjectType): current_language = graphene.String(required=True) + default_language = graphene.Field(LanguageType) available_languages = graphene.List(LanguageType) site_preferences = graphene.Field(SitePreferencesType) custom_menu_by_name = graphene.Field(CustomMenuType) @@ -27,6 +28,11 @@ class SystemPropertiesType(graphene.ObjectType): def resolve_current_language(parent, info, **kwargs): return info.context.LANGUAGE_CODE + @staticmethod + def resolve_default_language(root, info, **kwargs): + code = settings.LANGUAGE_CODE + return translation.get_language_info(code) | {"cookie": get_language_cookie(code)} + def resolve_available_languages(parent, info, **kwargs): return [ translation.get_language_info(code) | {"cookie": get_language_cookie(code)} diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 2923ba290c1364b20c5f2dda05f77aa2805fdc40..79ea1305cd2141509115da09db8de30a44d18914 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -156,6 +156,7 @@ INSTALLED_APPS = [ "rest_framework", "graphene_django", "dj_iconify.apps.DjIconifyConfig", + "recurrence", ] merge_app_settings("INSTALLED_APPS", INSTALLED_APPS, True) @@ -577,7 +578,7 @@ YARN_INSTALLED_APPS = [ "@fontsource/roboto@^4.5.5", "jquery@^3.6.0", "@materializecss/materialize@~1.0.0", - "material-design-icons-iconfont@^6.6.0", + "material-design-icons-iconfont@^6.7.0", "select2-materialize@^0.1.8", "paper-css@^0.4.1", "jquery-sortablejs@^1.0.1", @@ -588,6 +589,7 @@ YARN_INSTALLED_APPS = [ "@iconify/json@^2.1.30", "@mdi/font@^7.2.96", "apollo-boost@^0.4.9", + "apollo-link-batch-http@^1.2.14", "apollo-link-retry@^2.2.16", "apollo3-cache-persist@^0.14.1", "deepmerge@^4.2.2", diff --git a/aleksis/core/templates/account/email/email_confirmation_message.txt b/aleksis/core/templates/account/email/email_confirmation_message.txt new file mode 100644 index 0000000000000000000000000000000000000000..f795c60f7b66c75b37217caed8c38c3d7f9295e0 --- /dev/null +++ b/aleksis/core/templates/account/email/email_confirmation_message.txt @@ -0,0 +1,7 @@ +{% extends "account/email/base_message.txt" %} +{% load account %} +{% load html_helpers %} +{% load i18n %} + +{% block content %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Someone tried to register an account with the username {{ user_display }} and your e-mail address on {{ site_domain }}. +If it was you, please confirm the registration by clicking on the following link:{% endblocktrans %} {{ activate_url|remove_prefix:"/django/" }}{% endblock %} diff --git a/aleksis/core/tests/mixins/test_registry_object.py b/aleksis/core/tests/mixins/test_registry_object.py new file mode 100644 index 0000000000000000000000000000000000000000..d872e4a7078d27fd176a4b3f557bef3b55467fbb --- /dev/null +++ b/aleksis/core/tests/mixins/test_registry_object.py @@ -0,0 +1,57 @@ +import pytest + +from aleksis.core.mixins import RegistryObject + + +def test_registry_object_name(): + class ExampleRegistry(RegistryObject): + pass + + class ExampleWithManualName(ExampleRegistry): + name = "example_a" + + class ExampleWithAutomaticName(ExampleRegistry): + pass + + class ExampleWithOverridenAutomaticName(ExampleWithManualName): + name = "example_b" + + class ExampleWithOverridenManualName(ExampleWithAutomaticName): + name = "example_bb" + + assert ExampleRegistry.name == "" + assert ExampleWithManualName.name == "example_a" + assert ExampleWithAutomaticName.name == "ExampleWithAutomaticName" + assert ExampleWithOverridenAutomaticName.name == "example_b" + assert ExampleWithOverridenManualName.name == "example_bb" + + +def test_registry_object_registry(): + class ExampleRegistry(RegistryObject): + pass + + class ExampleA(ExampleRegistry): + pass + + class ExampleB(ExampleRegistry): + pass + + class ExampleAA(ExampleA): + name = "example_aa" + + assert ExampleRegistry.registered_objects_dict == { + "ExampleA": ExampleA, + "ExampleB": ExampleB, + "example_aa": ExampleAA, + } + assert ExampleRegistry.registered_objects_dict == ExampleRegistry._registry + + assert ExampleRegistry.registered_objects_list == [ + ExampleA, + ExampleB, + ExampleAA, + ] + + assert ExampleRegistry.get_object_by_name("ExampleA") == ExampleA + assert ExampleRegistry.get_object_by_name("ExampleB") == ExampleB + assert ExampleRegistry.get_object_by_name("example_aa") == ExampleAA diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 9792fcb75cd1e7197cda1f1e6707c3eb42ed1d3e..887a384247207c391f46b9e3eabfec870f863e9c 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -27,7 +27,7 @@ urlpatterns = [ path("__icons__/", include("dj_iconify.urls")), path( "graphql/", - csrf_exempt(views.LoggingGraphQLView.as_view(graphiql=True)), + csrf_exempt(views.LoggingGraphQLView.as_view(batch=True)), name="graphql", ), path("logo", force_maintenance_mode_off(views.LogoView.as_view()), name="logo"), @@ -42,11 +42,11 @@ urlpatterns = [ ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"), + path("", include("django_prometheus.urls")), path( "django/", include( [ - path("", include("django_prometheus.urls")), path("account/login/", views.LoginView.as_view(), name="login"), path( "accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup" @@ -397,6 +397,7 @@ urlpatterns = [ views.AssignPermissionView.as_view(), name="assign_permission", ), + path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"), ] ), ), diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index c7f89fb5badfbca1e5014b78a150129afd990669..65576a76ce333b870e8eaeba2656cd92ae1c60ea 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -19,9 +19,12 @@ from django.utils.crypto import get_random_string from django.utils.functional import lazy from django.utils.module_loading import import_string +import django_ical.feedgenerator as feedgenerator +import recurring_ical_events from cachalot.api import invalidate from cachalot.signals import post_invalidation from cache_memoize import cache_memoize +from icalendar import Calendar, Event, Todo def copyright_years(years: Sequence[int], separator: str = ", ", joiner: str = "–") -> str: @@ -489,3 +492,75 @@ def get_ip(*args, **kwargs): from ipware.ip import get_client_ip # noqa return get_client_ip(*args, **kwargs)[0] + + +feedgenerator.FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),) +feedgenerator.ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + ( + ("color", "color"), + ("recurrence_id", "recurrence-id"), +) + + +class ExtendedICal20Feed(feedgenerator.ICal20Feed): + """Extends the ICal20Feed class from django-ical. + + Adds a method to return the actual calendar object. + """ + + def get_calendar_object(self): + cal = Calendar() + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + + for ifield, efield in feedgenerator.FEED_FIELD_MAP: + val = self.feed.get(ifield) + if val is not None: + cal.add(efield, val) + + self.write_items(cal) + + return cal + + def write(self, outfile, encoding): + cal = Calendar() + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + + for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP: + val = self.feed.get(ifield) + if val is not None: + cal.add(efield, val) + + self.write_items(cal) + + to_ical = getattr(cal, "as_string", None) + if not to_ical: + to_ical = cal.to_ical + outfile.write(to_ical()) + + def write_items(self, calendar): + for item in self.items: + component_type = item.get("component_type") + if component_type == "todo": + element = Todo() + else: + element = Event() + for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP: + val = item.get(ifield) + if val is not None: + if ifield == "attendee": + for list_item in val: + element.add(efield, list_item) + elif ifield == "valarm": + for list_item in val: + element.add_component(list_item) + else: + element.add(efield, val) + calendar.add_component(element) + + def get_single_events(self, start=None, end=None): + """Get single event objects for this feed.""" + events = recurring_ical_events.of(self.get_calendar_object()) + if start and end: + return events.between(start, end) + return events.all() diff --git a/aleksis/core/views.py b/aleksis/core/views.py index c1de17adafc9bc33205a364b619b4c23c5b1e897..62f095450ea01e9295b97cfba2427683b7bb466a 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -97,7 +97,13 @@ from .forms import ( SelectPermissionForm, SitePreferenceForm, ) -from .mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView, SuccessNextMixin +from .mixins import ( + AdvancedCreateView, + AdvancedDeleteView, + AdvancedEditView, + CalendarEventMixin, + SuccessNextMixin, +) from .models import ( AdditionalField, Announcement, @@ -1585,3 +1591,18 @@ class LoggingGraphQLView(GraphQLView): if not isinstance(error.original_error, GraphQLError): raise error return result + + +class ICalFeedView(PermissionRequiredMixin, View): + """View to generate an iCal feed for a calendar.""" + + permission_required = "core.view_calendar_feed_rule" + + def get(self, request, name, *args, **kwargs): + if name in CalendarEventMixin.registered_objects_dict: + calendar = CalendarEventMixin.registered_objects_dict[name] + feed = calendar.create_feed(request) + response = HttpResponse(content_type="text/calendar") + feed.write(response, "utf-8") + return response + raise Http404 diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index 287534f096c186bc428de742c4e5ff56b71e8eab..f1402e7702877f33967158820fda5ecf4f4141db 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -60,6 +60,28 @@ function generateMessageImportCode(assetDir, name, importAppName) { return code; } +/** + * Generate code to import all components from a specified directory of a single AlekSIS app. + */ +function generateComponentsImportCode( + assetDir, + componentsDir, + name, + exportName +) { + let code = ""; + let componentsPath = assetDir + "/components" + componentsDir; + if (fs.existsSync(componentsPath)) { + const files = fs.readdirSync(componentsPath); + for (file of files) { + let componentName = file.split(".")[0]; + code += `import ${componentName} from '${componentsPath + file}';\n`; + code += `${exportName}["${componentName.toLowerCase()}"] = ${componentName};\n`; + } + } + return code; +} + /** * Generate a virtual module that helps the AlekSIS-Core frontend code import other apps. * @@ -69,6 +91,8 @@ function generateMessageImportCode(assetDir, name, importAppName) { function generateAppImporter(appDetails) { let code = "let appObjects = {};\n"; code += "let appMessages = {};\n"; + code += "let calendarFeedDetailComponents = {};\n"; + code += "let calendarFeedEventBarComponents = {};\n"; for (const [appPackage, appMeta] of Object.entries(appDetails)) { let indexPath = appMeta.assetDir + "/index.js"; @@ -86,13 +110,46 @@ function generateAppImporter(appDetails) { importAppName ); } + + // Include calendar feed detail components from all apps + code += generateComponentsImportCode( + appMeta.assetDir, + "/calendar_feeds/details/", + appMeta.name, + "calendarFeedDetailComponents" + ); + + // Include calendar feed event bar components from all apps + code += generateComponentsImportCode( + appMeta.assetDir, + "/calendar_feeds/event_bas/", + appMeta.name, + "calendarFeedEventBarComponents" + ); } // Include core messages code += generateMessageImportCode(django_values.coreAssetDir, "core", "Core"); + // Include core calendar feed detail components + code += generateComponentsImportCode( + django_values.coreAssetDir, + "/calendar_feeds/details/", + "core", + "calendarFeedDetailComponents" + ); + + // Include core calendar feed event bar components + code += generateComponentsImportCode( + django_values.coreAssetDir, + "/calendar_feeds/event_bar/", + "core", + "calendarFeedEventBarComponents" + ); + code += "export default appObjects;\n"; - code += "export { appObjects, appMessages };\n"; + code += + "export { appObjects, appMessages, calendarFeedDetailComponents, calendarFeedEventBarComponents };\n"; return code; } diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst index 1b82cf5e35c78585ee01b101120ceaff8fa19305..f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf 100644 --- a/docs/dev/01_setup.rst +++ b/docs/dev/01_setup.rst @@ -90,7 +90,7 @@ All three steps can be done with the ``poetry shell`` command and ``aleksis-admin``:: ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell - poetry run aleksis-admin yarn install + poetry run aleksis-admin vite build poetry run aleksis-admin collectstatic poetry run aleksis-admin compilemessages poetry run aleksis-admin migrate diff --git a/pyproject.toml b/pyproject.toml index eab2e8f450fd15f3312ac0305b658a813a65c549..1d4147cc2049f81bd4c83fbe8369ffac2002488d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,10 @@ graphene-django = "^3.0.0" selenium = "^4.4.3" django-vite = "^2.0.2" graphene-django-cud = "^0.10.0" +django-ical = "^1.8.3" +django-recurrence = "^1.11.1" +recurring-ical-events = "^2.0.2" +django-timezone-field = "^5.0" [tool.poetry.extras] ldap = ["django-auth-ldap"] @@ -146,5 +150,5 @@ line-length = 100 exclude = "/migrations/" [build-system] -requires = ["poetry>=1.0"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"