diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ac58628a8d6618ee244eb8aba0eed3aacfd36840..9872db38359a951199911ab881e4aa3305ffe4c7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,8 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Changes +======= The "managed models" feature is mandatory for all models derived from `ExtensibleModel` and requires creating a migration for all downstream models to add the respective field. @@ -17,6 +19,9 @@ Added ~~~~~ * Frontend for managing rooms. +* Global calendar system +* Calendar for birthdays of persons +* Holiday model to track information about holidays. * [Dev] Components for implementing standard CRUD operations in new frontend. * [Dev] Options for filtering and sorting of GraphQL queries at the server. * [Dev] Managed models for instances handled by other apps. diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js index 567f9a196f61052ff0481c1f975a6c0d2433964e..2cea6431fa0714ebd4fd303adab620bcda94da92 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/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..6b50ee5ba208b225709e6963ec679e826ceead82 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue @@ -0,0 +1,94 @@ +<template> + <v-menu + v-model="model" + :close-on-content-click="false" + :activator="selectedElement" + :offset-x="calendarType !== 'day'" + min-width="350px" + :offset-y="calendarType === 'day'" + > + <v-card min-width="350px" flat> + <v-toolbar :color="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"> + <cancelled-calendar-status-chip + v-if="selectedEvent.status === 'CANCELLED' && !withoutBadge" + /> + </slot> + </v-toolbar> + <slot name="time" :selected-event="selectedEvent"> + <v-list-item v-if="!withoutTime"> + <v-list-item-icon> + <v-icon color="primary">mdi-calendar-today-outline</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> + <span + v-if=" + selectedEvent.allDay && + selectedEvent.start.getTime() === selectedEvent.end.getTime() + " + > + {{ $d(selectedEvent.start, "short") }} + </span> + <span v-else-if="selectedEvent.allDay"> + {{ $d(selectedEvent.start, "short") }} – + {{ $d(selectedEvent.end, "short") }} + </span> + <span + v-else-if=" + dateWithoutTime(selectedEvent.start).getTime() === + dateWithoutTime(selectedEvent.end).getTime() + " + > + {{ $d(selectedEvent.start, "shortDateTime") }} – + {{ $d(selectedEvent.end, "shortTime") }} + </span> + <span v-else> + {{ $d(selectedEvent.start, "shortDateTime") }} – + {{ $d(selectedEvent.end, "shortDateTime") }} + </span> + </v-list-item-title> + </v-list-item-content> + </v-list-item> + </slot> + <slot name="description" :selected-event="selectedEvent"> + <v-divider + inset + v-if="selectedEvent.description && !withoutDescription" + /> + <v-list-item v-if="selectedEvent.description && !withoutDescription"> + <v-list-item-icon> + <v-icon color="primary">mdi-card-text-outline</v-icon> + </v-list-item-icon> + <v-list-item-content style="white-space: pre-line"> + {{ selectedEvent.description }} + </v-list-item-content> + </v-list-item> + </slot> + </v-card> + </v-menu> +</template> + +<script> +import calendarFeedDetailsMixin from "../../mixins/calendarFeedDetails.js"; +import CancelledCalendarStatusChip from "./CancelledCalendarStatusChip.vue"; + +export default { + name: "BaseCalendarFeedDetails", + components: { CancelledCalendarStatusChip }, + mixins: [calendarFeedDetailsMixin], + methods: { + dateWithoutTime(d) { + d = new Date(d); + d.setHours(0, 0, 0, 0); + return d; + }, + }, +}; +</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..1e1d1436897ad17a1875f0bf8a9cf20d20033310 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue @@ -0,0 +1,56 @@ +<template> + <div + class="text-truncate" + :class="{ + 'text-decoration-line-through': event.status === 'CANCELLED', + 'mx-1': withPadding, + }" + :style="{ height: '100%' }" + > + <slot name="time" v-bind="$props"> + <span + v-if=" + calendarType === 'month' && eventParsed.start.hasTime && !withoutTime + " + class="mr-1 font-weight-bold ml-1" + > + {{ 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], + props: { + withPadding: { + required: false, + type: Boolean, + default: true, + }, + icon: { + required: false, + type: String, + default: "", + }, + withoutTime: { + required: false, + type: Boolean, + default: false, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..c3f9c45c424aa73c0178ea15882e4e6783a73e43 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue @@ -0,0 +1,21 @@ +<script> +export default { + name: "CalendarControlBar", + emits: ["prev", "next", "today"], +}; +</script> + +<template> + <div class="d-flex justify-center"> + <v-btn icon class="mx-2" @click="$emit('prev')"> + <v-icon>mdi-chevron-left</v-icon> + </v-btn> + <v-btn outlined text class="mx-2" @click="$emit('today')"> + <v-icon left>mdi-calendar-today-outline</v-icon> + {{ $t("calendar.today") }} + </v-btn> + <v-btn icon class="mx-2" @click="$emit('next')"> + <v-icon>mdi-chevron-right</v-icon> + </v-btn> + </div> +</template> diff --git a/aleksis/core/frontend/components/calendar/CalendarOverview.vue b/aleksis/core/frontend/components/calendar/CalendarOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..7153b8e1e8dce599fc00527d28bc327a713e4ed4 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue @@ -0,0 +1,329 @@ +<template> + <div class="mt-4 mb-4"> + <v-skeleton-loader + v-if=" + $apollo.queries.calendar.loading && calendar.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"> + <!-- Control bar with prev, next and today buttons --> + <v-col cols="12" sm="4" lg="3" xl="2" align-self="center"> + <calendar-control-bar + @prev="$refs.calendar.prev()" + @next="$refs.calendar.next()" + @today="calendarFocus = new Date()" + /> + </v-col> + + <!-- Calendar title with current calendar time range --> + <v-col v-if="$vuetify.breakpoint.lgAndUp" align-self="center"> + <h1 class="mx-2" v-if="$refs.calendar">{{ $refs.calendar.title }}</h1> + </v-col> + + <!-- Button menu for selecting currently active calendars (only tablets/mobile) --> + <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="calendar.calendarFeeds" + @input="storeActivatedCalendars" + /> + </button-menu> + </v-col> + + <v-spacer v-if="$vuetify.breakpoint.lgAndUp" /> + + <!-- Calendar type select (month, week, day) --> + <v-col + cols="12" + sm="4" + lg="3" + align-self="center" + :align="$vuetify.breakpoint.smAndUp ? 'right' : 'center'" + > + <calendar-type-select v-model="currentCalendarType" /> + </v-col> + </v-row> + <v-row> + <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2"> + <!-- Mini date picker --> + <v-date-picker + no-title + v-model="calendarFocus" + :style="{ margin: '0px -8px' }" + :first-day-of-week="1" + ></v-date-picker> + + <!-- Calendar select (only desktop) --> + <v-list flat subheader> + <v-subheader> + {{ $t("calendar.my_calendars") }} + </v-subheader> + <calendar-select + class="mb-4" + v-model="selectedCalendarFeedNames" + :calendar-feeds="calendar.calendarFeeds" + @input="storeActivatedCalendars" + /> + <v-btn depressed block v-if="calendar" :href="calendar.allFeedsUrl"> + <v-icon left>mdi-download-outline</v-icon> + {{ $t("calendar.download_all") }} + </v-btn> + </v-list> + </v-col> + + <!-- Actual calendar --> + <v-col lg="9" xl="10"> + <v-sheet height="600"> + <v-expand-transition> + <v-progress-linear + v-if="$apollo.queries.calendar.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="calendar && calendar.calendarFeeds && selectedEvent" + :is="detailComponentForFeed(selectedEvent.calendarFeedName)" + v-model="selectedOpen" + :selected-element="selectedElement" + :selected-event="selectedEvent" + :calendar-type="currentCalendarType" + /> + </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"; +import gqlSetCalendarStatus from "./setCalendarStatus.graphql"; +import CalendarControlBar from "./CalendarControlBar.vue"; +import CalendarTypeSelect from "./CalendarTypeSelect.vue"; + +export default { + name: "CalendarOverview", + components: { + CalendarTypeSelect, + CalendarControlBar, + ButtonMenu, + CalendarSelect, + }, + data() { + return { + calendarFocus: "", + calendar: { + calendarFeeds: [], + }, + selectedCalendarFeedNames: [], + currentCalendarType: "week", + selectedEvent: {}, + selectedElement: null, + selectedOpen: false, + fetchedDateRange: { start: null, end: null }, + }; + }, + apollo: { + calendar: { + query: gqlCalendarOverview, + skip: true, + result({ data }) { + this.selectedCalendarFeedNames = data.calendar.calendarFeeds + .filter((c) => c.activated) + .map((c) => c.name); + }, + }, + }, + computed: { + events() { + return this.calendar.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, + meta: JSON.parse(event.meta), + })) + ); + }, + }, + methods: { + 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.calendar.calendarFeeds && + feedName && + Object.keys(calendarFeedDetailComponents).includes(feedName + "details") + ) { + return calendarFeedDetailComponents[feedName + "details"]; + } + return GenericCalendarFeedDetails; + }, + eventBarComponentForFeed(feedName) { + if ( + this.calendar.calendarFeeds && + feedName && + Object.keys(calendarFeedEventBarComponents).includes( + feedName + "eventbar" + ) + ) { + return calendarFeedEventBarComponents[feedName + "eventbar"]; + } + return GenericCalendarFeedEventBar; + }, + getColorForEvent(event) { + return event.color; + }, + storeActivatedCalendars() { + // Store currently activated calendars in the backend + this.$apollo.mutate({ + mutation: gqlSetCalendarStatus, + variables: { + calendars: this.selectedCalendarFeedNames, + }, + }); + }, + 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.calendar.calendarFeeds.length === 0) { + // No calendar feeds have been fetched yet, + // so fetch all events in the current date range + + this.$apollo.queries.calendar.setVariables({ + start: extendedStart, + end: extendedEnd, + }); + this.$apollo.queries.calendar.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.calendar.fetchMore({ + variables: { + start: fetchStart, + end: fetchEnd, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + let previousCalendarFeeds = previousResult.calendar.calendarFeeds; + let newCalendarFeeds = fetchMoreResult.calendar.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 { + calendar: { + ...previousResult.calendar, + 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..5748b1d5755b961ca901f075b4aac152f3874634 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue @@ -0,0 +1,85 @@ +<template> + <v-list-item-group multiple v-model="model"> + <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" + :color="calendarFeed.color" + ></v-checkbox> + </v-list-item-action> + + <v-list-item-content> + <v-list-item-title> + {{ calendarFeed.verboseName }} + </v-list-item-title> + </v-list-item-content> + + <v-list-item-action> + <v-menu bottom> + <template v-slot:activator="{ on, attrs }"> + <v-btn fab x-small icon v-bind="attrs" v-on="on"> + <v-icon>mdi-dots-vertical</v-icon> + </v-btn> + </template> + <v-list dense> + <v-list-item :href="calendarFeed.url"> + <v-list-item-icon> + <v-icon>mdi-calendar-export</v-icon> + </v-list-item-icon> + <v-list-item-title> + {{ $t("calendar.download_ics") }} + </v-list-item-title> + </v-list-item> + </v-list> + </v-menu> + </v-list-item-action> + </template> + </v-list-item> + </v-list-item-group> +</template> + +<script> +export default { + name: "CalendarSelect", + props: { + calendarFeeds: { + type: Array, + required: true, + }, + value: { + type: Array, + required: true, + }, + }, + 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/CalendarStatusChip.vue b/aleksis/core/frontend/components/calendar/CalendarStatusChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..679749135dd12e302f18a797af4d8fb169a44470 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarStatusChip.vue @@ -0,0 +1,24 @@ +<script> +export default { + name: "CalendarStatusChip", + props: { + color: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <v-chip :color="color" label> + <v-avatar left> + <v-icon>{{ icon }}</v-icon> + </v-avatar> + <slot></slot> + </v-chip> +</template> diff --git a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue new file mode 100644 index 0000000000000000000000000000000000000000..1868b314a1af1106fe30b54571f629f2a577995f --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue @@ -0,0 +1,61 @@ +<script> +export default { + name: "CalendarTypeSelect", + props: { + value: { + type: String, + required: true, + }, + }, + data() { + return { + innerValue: this.value, + 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", + }, + ], + }; + }, + watch: { + value(val) { + this.innerValue = val; + }, + innerValue(val) { + this.$emit("input", val); + }, + }, + methods: { + nameForMenu(item) { + return this.$t(item.translationKey); + }, + }, +}; +</script> + +<template> + <v-btn-toggle dense v-model="innerValue" class="mx-2"> + <v-btn + v-for="calendarType in availableCalendarTypes" + :value="calendarType.type" + :key="calendarType.type" + > + <v-icon v-if="$vuetify.breakpoint.smAndDown">{{ + "mdi-" + calendarType.icon + }}</v-icon> + <span class="hidden-sm-and-down">{{ nameForMenu(calendarType) }}</span> + </v-btn> + </v-btn-toggle> +</template> diff --git a/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue b/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..00b816943654453690f32da1db95e14f57b7dce7 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/CancelledCalendarStatusChip.vue @@ -0,0 +1,14 @@ +<script> +import CalendarStatusChip from "./CalendarStatusChip.vue"; + +export default { + name: "CancelledCalendarStatusChip", + components: { CalendarStatusChip }, +}; +</script> + +<template> + <calendar-status-chip icon="mdi-cancel" color="error"> + {{ $t("calendar.cancelled") }} + </calendar-status-chip> +</template> diff --git a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..2b6f8fa1e5833260196f97a4e939187f87c80c3f --- /dev/null +++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue @@ -0,0 +1,25 @@ +<template> + <div> + <base-calendar-feed-details v-bind="$props" /> + <v-divider inset v-if="selectedEvent.location" /> + <v-list-item v-if="selectedEvent.location"> + <v-list-item-icon> + <v-icon color="primary">mdi-map-marker-outline</v-icon> + </v-list-item-icon> + <v-list-item-content> + {{ selectedEvent.location }} + </v-list-item-content> + </v-list-item> + </div> +</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..f277ef4bbffcf9cf68532a5e1ba9ce3ecd2c813b --- /dev/null +++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql @@ -0,0 +1,27 @@ +query ($start: Date, $end: Date) { + calendar { + allFeedsUrl + calendarFeeds { + name + verboseName + description + url + color + activated + feed { + events(start: $start, end: $end) { + name + start + end + color + description + location + uid + allDay + status + meta + } + } + } + } +} diff --git a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql new file mode 100644 index 0000000000000000000000000000000000000000..633f0791c3e00f0c82886779366704086871c484 --- /dev/null +++ b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql @@ -0,0 +1,5 @@ +mutation ($calendars: [String]!) { + setCalendarStatus(calendars: $calendars) { + ok + } +} 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/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/messages/en.json b/aleksis/core/frontend/messages/en.json index ceda00c3994bccf4fc2c7b5e9cb83fe5ec2d00d9..a477736639405a1005f8799ce09b6c88f45a95de 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", @@ -252,6 +256,19 @@ "new_version_available": "A new version of the app is available", "update": "Update" }, + "calendar": { + "menu_title": "Calendar", + "month": "Month", + "week": "Week", + "day": "Day", + "today": "Today", + "select": "Select calendars", + "ics_to_clipboard": "Copy link to calendar ICS to clipboard", + "cancelled": "Cancelled", + "download_ics": "Download ICS", + "my_calendars": "My Calendars", + "download_all": "Download all" + }, "graphql": { "snackbar_error_message": "There was an error retrieving the page data. Please try again.", "snackbar_success_message": "The operation has been finished successfully." diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js new file mode 100644 index 0000000000000000000000000000000000000000..0f729ba18355f7742d9080ea0188e8f3ba73e193 --- /dev/null +++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js @@ -0,0 +1,52 @@ +/** + * 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: false, + type: Boolean, + default: false, + }, + withoutDescription: { + required: false, + type: Boolean, + default: false, + }, + withoutBadge: { + required: false, + type: Boolean, + default: false, + }, + color: { + required: false, + type: String, + default: null, + }, + calendarType: { + required: true, + type: String, + }, + }, + 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..3b6590b0f68e75090b200bd0aa0fcc747cfdef51 --- /dev/null +++ b/aleksis/core/frontend/mixins/calendarFeedEventBar.js @@ -0,0 +1,21 @@ +/** + * 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, + }, + }, +}; + +export default calendarFeedEventBarMixin; diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 84b3612f33510940b97dd57fec5370e49c768d4c..b14cb40e77c84230a1555b296eda94d245df9cfb 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 = [ { @@ -1049,6 +1049,18 @@ const routes = [ }, name: "invitations.accept_invite", }, + { + path: "/calendar/", + component: () => import("./components/calendar/CalendarOverview.vue"), + name: "core.calendar_overview", + meta: { + inMenu: true, + icon: "mdi-calendar", + titleKey: "calendar.menu_title", + toolbarTitle: "calendar.menu_title", + permission: "core.view_calendar_feed_rule", + }, + }, ]; // This imports all known AlekSIS app entrypoints diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index c79e756f509ea428359c95617e3e84d573985cf2..07237fdac02af7750a2000f89167cb9bbc217a5c 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 AlekSISBaseManager(_CurrentSiteManager): @@ -141,3 +141,14 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager): class PolymorphicCurrentSiteManager(AlekSISBaseManager, 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/0051_calendarevent_and_holiday.py b/aleksis/core/migrations/0051_calendarevent_and_holiday.py new file mode 100644 index 0000000000000000000000000000000000000000..28e5294027d766f64d6cb644696e84f7a9c2a6ac --- /dev/null +++ b/aleksis/core/migrations/0051_calendarevent_and_holiday.py @@ -0,0 +1,164 @@ +# Generated by Django 4.1.10 on 2023-07-11 19:01 + +import aleksis.core.managers +import aleksis.core.mixins +from django.db import migrations, models +import django.db.models.deletion +import recurrence.fields +import timezone_field.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("sites", "0002_alter_domain_unique"), + ("core", "0050_managed_by_app_label"), + ] + + operations = [ + migrations.CreateModel( + name="CalendarEvent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "managed_by_app_label", + models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + ("extended_data", models.JSONField(default=dict, editable=False)), + ( + "datetime_start", + models.DateTimeField(blank=True, null=True, verbose_name="Start date and time"), + ), + ( + "datetime_end", + models.DateTimeField(blank=True, null=True, verbose_name="End date and time"), + ), + ( + "timezone", + timezone_field.fields.TimeZoneField( + blank=True, null=True, verbose_name="Timezone" + ), + ), + ("date_start", models.DateField(blank=True, null=True, verbose_name="Start date")), + ("date_end", models.DateField(blank=True, null=True, verbose_name="End date")), + ( + "recurrences", + recurrence.fields.RecurrenceField( + blank=True, null=True, verbose_name="Recurrences" + ), + ), + ( + "amends", + 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", + ), + ), + ( + "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, + related_name="+", + to="sites.site", + ), + ), + ], + options={ + "verbose_name": "Calendar Event", + "verbose_name_plural": "Calendar Events", + "ordering": ["datetime_start", "date_start", "datetime_end", "date_end"], + }, + bases=(aleksis.core.mixins.CalendarEventMixin, models.Model), + managers=[ + ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + 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",), + managers=[ + ("objects", aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + 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", + ), + ), + 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 dd43fbb0f5ddc1e324f56bf1271f023f630b690d..c6c19af0a5f58a105377bb2a36502b562056739d 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, fields_for_model -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 Fieldset, Layout, LayoutNode from polymorphic.base import PolymorphicModelBase @@ -40,6 +43,8 @@ from aleksis.core.managers import ( SchoolTermRelatedQuerySet, ) +from .util.core_helpers import ExtendedICal20Feed + class _ExtensibleModelBase(models.base.ModelBase): """Ensure predefined behaviour on model creation. @@ -571,7 +576,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): @@ -583,23 +588,178 @@ 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): + def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]: + """Get registered object by name.""" return cls.registered_objects_dict.get(name) class ObjectAuthenticator(RegistryObject): def authenticate(self, request, obj): raise NotImplementedError() + + +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_feed(cls): + """Return if the feed is valid.""" + return cls.name != cls.__name__ + + @classproperty + def valid_feeds(cls): + """Return a list of valid feeds.""" + return [feed for feed in cls.registered_objects_list if feed.valid_feed] + + @classproperty + def valid_feed_names(cls): + """Return a list of valid feed names.""" + return [feed.name for feed in cls.valid_feeds] + + def get_object_by_name(cls, name): + return cls.registered_objects_dict.get(name) diff --git a/aleksis/core/models.py b/aleksis/core/models.py index d90790427fb3c22531ec0cda50e9c48eb615e196..4151d51b39d4cafac8ba91250a5ed64b8d4af50e 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,270 @@ 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, None]: + """Return the end datetime of the calendar event.""" + if reference_object.datetime_end: + return reference_object.datetime_end.astimezone(reference_object.timezone) + if reference_object.date_end == reference_object.date_start: + # Rule for all day events: If the event is only one day long, + # the end date has to be empty + return None + return reference_object.date_end + + @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..a3be1ac89770a89c2d3ea82b64aee7adc3913c9f 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -16,7 +16,7 @@ from dynamic_preferences.types import ( ) from oauth2_provider.models import AbstractApplication -from .mixins import PublicFilePreferenceMixin +from .mixins import CalendarEventMixin, PublicFilePreferenceMixin from .models import Group, Person from .registries import person_preferences_registry, site_preferences_registry from .util.notifications import get_notification_choices_lazy @@ -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,42 @@ 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 + + +@person_preferences_registry.register +class ActivatedCalendars(MultipleChoicePreference): + """Calendars that are activated for a person.""" + + section = calendar + name = "activated_calendars" + default = [] + widget = SelectMultiple + verbose_name = _("Activated calendars") + required = False + + field_attribute = {"initial": []} + choices = [(feed.name, feed.verbose_name) for feed in CalendarEventMixin.valid_feeds] 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 ae137be9da07fb643e17e00fd2111892802cd24b..604e0d6cbb4b1f5c08c244c42ddc42b3e4084bbb 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -23,6 +23,7 @@ from ..models import ( from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person from .base import FilterOrderList +from .calendar import CalendarBaseType, SetCalendarStatusMutation from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType from .dynamic_routes import DynamicRouteType @@ -93,6 +94,8 @@ class Query(graphene.ObjectType): school_terms = FilterOrderList(SchoolTermType) + calendar = graphene.Field(CalendarBaseType) + def resolve_ping(root, info, payload) -> str: return payload @@ -209,6 +212,10 @@ class Query(graphene.ObjectType): return room_object + @staticmethod + def resolve_calendar(root, info, **kwargs): + return True + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() @@ -229,6 +236,8 @@ class Mutation(graphene.ObjectType): delete_school_terms = SchoolTermBatchDeleteMutation.Field() update_school_terms = SchoolTermBatchPatchMutation.Field() + set_calendar_status = SetCalendarStatusMutation.Field() + def build_global_schema(): """Build global GraphQL schema from all apps.""" diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..110e2cf826f26314b278bf293724712473a61ba8 --- /dev/null +++ b/aleksis/core/schema/calendar.py @@ -0,0 +1,127 @@ +from datetime import datetime + +from django.core.exceptions import PermissionDenied +from django.urls import reverse + +import graphene +from graphene import ObjectType + +from aleksis.core.mixins import CalendarEventMixin +from aleksis.core.util.core_helpers import has_person + + +class CalendarEventType(ObjectType): + name = graphene.String() + description = graphene.String() + location = graphene.String(required=False) + start = graphene.String() + end = graphene.String() + color = graphene.String() + uid = graphene.String() + all_day = graphene.Boolean() + status = graphene.String() + meta = graphene.String() + + def resolve_name(root, info, **kwargs): + return root["SUMMARY"] + + def resolve_description(root, info, **kwargs): + return root["DESCRIPTION"] + + def resolve_location(root, info, **kwargs): + return root.get("LOCATION", "") + + 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", "") + + def resolve_meta(root, info, **kwargs): + return root.get("X-META", "{}") + + +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() + + activated = graphene.Boolean() + + 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) + + def resolve_activated(root, info, **kwargs): + return root.name in info.context.user.person.preferences["calendar__activated_calendars"] + + +class SetCalendarStatusMutation(graphene.Mutation): + """Mutation to change the status of a calendar.""" + + class Arguments: + calendars = graphene.List(graphene.String) + + ok = graphene.Boolean() + + def mutate(root, info, calendars, **kwargs): + if not has_person(info.context): + raise PermissionDenied + calendar_feeds = [cal for cal in calendars if cal in CalendarEventMixin.valid_feed_names] + info.context.user.person.preferences["calendar__activated_calendars"] = calendar_feeds + return SetCalendarStatusMutation(ok=True) + + +class CalendarBaseType(ObjectType): + calendar_feeds = graphene.List(CalendarType) + + calendar_feeds_by_names = graphene.List(CalendarType, names=graphene.List(graphene.String)) + + all_feeds_url = graphene.String() + + 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] + + def resolve_all_feeds_url(root, info, **kwargs): + return info.context.build_absolute_uri(reverse("all_calendar_feeds")) diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 567fada118b295e763f1d202f23009626216a880..8307ae9c16e65073b2fcc890d7aeeac4cb9bf408 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) 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 5f1ce87cc3b51e462b61f0f109ce03c913c9c851..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -401,6 +401,8 @@ urlpatterns = [ views.AssignPermissionView.as_view(), name="assign_permission", ), + path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"), + path("feeds.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"), ] ), ), diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index c7f89fb5badfbca1e5014b78a150129afd990669..9329dda6192b554efa5e546a0160b5225e09da17 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -1,3 +1,4 @@ +import json import os from datetime import datetime, timedelta from importlib import import_module, metadata @@ -19,9 +20,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 +493,69 @@ 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"), + ("meta", "x-meta"), +) + + +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, with_meta=True): + 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, with_meta=with_meta) + + return cal + + def write(self, outfile, encoding): + cal = self.get_calendar_object(with_meta=False) + + 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, with_meta=True): + 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) + elif ifield == "meta": + if with_meta: + element.add(efield, json.dumps(val)) + 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 abe9ebbb9e98071c28069308e87f401b99d80574..f9e21e5b57472664e96bded0ebb471a11aff014b 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -100,6 +100,7 @@ from .mixins import ( AdvancedCreateView, AdvancedDeleteView, AdvancedEditView, + CalendarEventMixin, ObjectAuthenticator, SuccessNextMixin, ) @@ -1626,3 +1627,31 @@ class ObjectRepresentationView(View): return True raise PermissionDenied() + + +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 + + +class ICalAllFeedsView(PermissionRequiredMixin, View): + """View to generate an iCal feed for all calendars.""" + + permission_required = "core.view_calendar_feed_rule" + + def get(self, request, *args, **kwargs): + response = HttpResponse(content_type="text/calendar") + for calendar in CalendarEventMixin.valid_feeds: + feed = calendar.create_feed(request) + feed.write(response, "utf-8") + return response diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index ec24281c7b1b8576bfae100c1128cea8a0d6e9b4..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 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_bar/", + 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/pyproject.toml b/pyproject.toml index a591d8b60936316f005f14b1db47f185322e4cb9..73fd5c29b41fcb4d64940a4c4f43a296e807e4e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,6 +129,10 @@ selenium = "^4.4.3" django-vite = "^2.0.2" graphene-django-cud = "^0.11.0" uwsgi = "^2.0.21" +django-ical = "^1.9.2" +django-recurrence = "^1.11.1" +recurring-ical-events = "^2.0.2" +django-timezone-field = "^5.0" [tool.poetry.extras] ldap = ["django-auth-ldap"]