diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 31a7de8c22915f4e4955a8ad7526ddaeabef56e5..ebbc04a158ae63f7ece500b7ac515d3f6b224c56 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,10 +19,14 @@ 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. * [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients +* Generic endpoint for retrieving objects as JSON Changed ~~~~~~~ @@ -34,6 +40,7 @@ Fixed in an incomplete AlekSIS frontend app. * GraphQL mutations did not return errors in case of exceptions. * Rendering of "simple" PDF templates failed when used with S3 storage. +* Log messages on some loggers did not contain log message `3.1.2`_ - 2023-07-05 --------------------- diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js index f80eb0ac531c075935acdb2249ec5c1676267891..062478e5a2d28ee807406fd0d6828a79f447dac4 100644 --- a/aleksis/core/frontend/app/vuetify.js +++ b/aleksis/core/frontend/app/vuetify.js @@ -28,7 +28,7 @@ const vuetifyOpts = { filterEmpty: "mdi-filter-outline", filterSet: "mdi-filter", send: "mdi-send-outline", - holidays: "mdi-calendar-weekend-outline" + holidays: "mdi-calendar-weekend-outline", }, }, }; diff --git a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue index a9dc64fdb9bfd5713d68d2ebf926b78ad255ea51..6b50ee5ba208b225709e6963ec679e826ceead82 100644 --- a/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedDetails.vue @@ -3,7 +3,9 @@ v-model="model" :close-on-content-click="false" :activator="selectedElement" - offset-x + :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> @@ -26,13 +28,31 @@ </v-list-item-icon> <v-list-item-content> <v-list-item-title> - <span v-if="selectedEvent.start !== selectedEvent.end"> + <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, "shortDateTime") }} + {{ $d(selectedEvent.end, "shortTime") }} </span> <span v-else> - {{ $d(selectedEvent.start, "shortDateTime") }}</span - > + {{ $d(selectedEvent.start, "shortDateTime") }} – + {{ $d(selectedEvent.end, "shortDateTime") }} + </span> </v-list-item-title> </v-list-item-content> </v-list-item> @@ -63,5 +83,12 @@ 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 index 205cbb13a1c150555bec1e222244c2b361d31053..1e1d1436897ad17a1875f0bf8a9cf20d20033310 100644 --- a/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue +++ b/aleksis/core/frontend/components/calendar/BaseCalendarFeedEventBar.vue @@ -1,7 +1,10 @@ <template> <div class="text-truncate" - :class="{ 'text-decoration-line-through': event.status === 'CANCELLED', 'mx-1': withPadding }" + :class="{ + 'text-decoration-line-through': event.status === 'CANCELLED', + 'mx-1': withPadding, + }" :style="{ height: '100%' }" > <slot name="time" v-bind="$props"> @@ -9,9 +12,10 @@ v-if=" calendarType === 'month' && eventParsed.start.hasTime && !withoutTime " - class="mr-1 font-weight-bold" - >{{ eventParsed.start.time }}</span + 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"> @@ -31,13 +35,13 @@ import calendarFeedEventBarMixin from "../../mixins/calendarFeedEventBar.js"; export default { name: "BaseCalendarFeedEventBar", mixins: [calendarFeedEventBarMixin], - props: { - withPadding: { - required: false, - type: Boolean, - default: true, + props: { + withPadding: { + required: false, + type: Boolean, + default: true, }, - icon: { + icon: { required: false, type: String, default: "", @@ -47,6 +51,6 @@ export default { 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 index db9d10a0ab8dfc30bcb68862646b1875a050f9a0..7153b8e1e8dce599fc00527d28bc327a713e4ed4 100644 --- a/aleksis/core/frontend/components/calendar/CalendarOverview.vue +++ b/aleksis/core/frontend/components/calendar/CalendarOverview.vue @@ -1,7 +1,9 @@ <template> <div class="mt-4 mb-4"> <v-skeleton-loader - v-if="$apollo.queries.calendarFeeds.loading && calendarFeeds.length === 0" + v-if=" + $apollo.queries.calendar.loading && calendar.calendarFeeds.length === 0 + " type="date-picker-options, actions" /> <div v-else> @@ -12,28 +14,21 @@ {{ $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> + <!-- 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" @@ -47,11 +42,15 @@ > <calendar-select v-model="selectedCalendarFeedNames" - :calendar-feeds="calendarFeeds" + :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" @@ -59,36 +58,43 @@ 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> + <calendar-type-select v-model="currentCalendarType" /> </v-col> </v-row> <v-row> <v-col v-if="$vuetify.breakpoint.lgAndUp" lg="3" xl="2"> - <v-list flat> + <!-- 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="calendarFeeds" + :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.calendarFeeds.loading" + v-if="$apollo.queries.calendar.loading" indeterminate /> </v-expand-transition> @@ -115,11 +121,12 @@ </template> </v-calendar> <component - v-if="calendarFeeds && selectedEvent" + 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> @@ -140,51 +147,46 @@ import { } 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: "", - calendarFeeds: [], + calendar: { + 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: { + calendar: { query: gqlCalendarOverview, skip: true, + result({ data }) { + this.selectedCalendarFeedNames = data.calendar.calendarFeeds + .filter((c) => c.activated) + .map((c) => c.name); + }, }, }, computed: { events() { - return this.calendarFeeds + return this.calendar.calendarFeeds .filter((c) => this.selectedCalendarFeedNames.includes(c.name)) .flatMap((cf) => cf.feed.events.map((event) => ({ @@ -201,9 +203,6 @@ export default { }, }, methods: { - nameForMenu: function (item) { - return this.$t(item.translationKey); - }, viewDay({ date }) { this.calendarFocus = date; this.currentCalendarType = "day"; @@ -228,7 +227,7 @@ export default { }, detailComponentForFeed(feedName) { if ( - this.calendarFeeds && + this.calendar.calendarFeeds && feedName && Object.keys(calendarFeedDetailComponents).includes(feedName + "details") ) { @@ -238,7 +237,7 @@ export default { }, eventBarComponentForFeed(feedName) { if ( - this.calendarFeeds && + this.calendar.calendarFeeds && feedName && Object.keys(calendarFeedEventBarComponents).includes( feedName + "eventbar" @@ -251,6 +250,15 @@ export default { 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; @@ -259,15 +267,15 @@ export default { let olderStart = extendedStart < this.fetchedDateRange.start; let youngerEnd = extendedEnd > this.fetchedDateRange.end; - if (this.calendarFeeds.length === 0) { + 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.calendarFeeds.setVariables({ + this.$apollo.queries.calendar.setVariables({ start: extendedStart, end: extendedEnd, }); - this.$apollo.queries.calendarFeeds.skip = false; + this.$apollo.queries.calendar.skip = false; this.fetchedDateRange = { start: extendedStart, end: extendedEnd }; } else if (olderStart || youngerEnd) { // Define newly fetched date range @@ -278,14 +286,14 @@ export default { let fetchStart = olderStart ? extendedStart : this.fetchedDateRange.end; let fetchEnd = youngerEnd ? extendedEnd : this.fetchedDateRange.start; - this.$apollo.queries.calendarFeeds.fetchMore({ + this.$apollo.queries.calendar.fetchMore({ variables: { start: fetchStart, end: fetchEnd, }, updateQuery: (previousResult, { fetchMoreResult }) => { - let previousCalendarFeeds = previousResult.calendarFeeds; - let newCalendarFeeds = fetchMoreResult.calendarFeeds; + let previousCalendarFeeds = previousResult.calendar.calendarFeeds; + let newCalendarFeeds = fetchMoreResult.calendar.calendarFeeds; previousCalendarFeeds.forEach((calendarFeed, i, calendarFeeds) => { // Get all events except those that are updated @@ -300,7 +308,10 @@ export default { ]; }); return { - calendarFeeds: previousCalendarFeeds, + calendar: { + ...previousResult.calendar, + calendarFeeds: previousCalendarFeeds, + }, }; }, }); diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue index 04421cb09ee6c9e6a23bd59a5ad4eb5e7c37d938..5748b1d5755b961ca901f075b4aac152f3874634 100644 --- a/aleksis/core/frontend/components/calendar/CalendarSelect.vue +++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue @@ -1,13 +1,5 @@ <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" @@ -15,13 +7,12 @@ > <template #default="{ active }"> <v-list-item-action> - <v-checkbox :input-value="active"></v-checkbox> + <v-checkbox + :input-value="active" + :color="calendarFeed.color" + ></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 }} @@ -29,10 +20,23 @@ </v-list-item-content> <v-list-item-action> - <copy-to-clipboard-button - :text="calendarFeed.url" - :tooltip-help-text="$t('calendar.ics_to_clipboard')" - /> + <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> @@ -40,8 +44,6 @@ </template> <script> -import CopyToClipboardButton from "../generic/CopyToClipboardButton.vue"; - export default { name: "CalendarSelect", props: { @@ -54,9 +56,6 @@ export default { required: true, }, }, - components: { - CopyToClipboardButton, - }, computed: { model: { get() { 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/GenericCalendarFeedDetails.vue b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue index 4c890629fb1662ccfa21102194d6dbbf4f2c40df..2b6f8fa1e5833260196f97a4e939187f87c80c3f 100644 --- a/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue +++ b/aleksis/core/frontend/components/calendar/GenericCalendarFeedDetails.vue @@ -1,5 +1,16 @@ <template> - <base-calendar-feed-details v-bind="$props" /> + <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> diff --git a/aleksis/core/frontend/components/calendar/calendarOverview.graphql b/aleksis/core/frontend/components/calendar/calendarOverview.graphql index 777d2f5253ab24e7c07378eaf57e033f5bee874d..f277ef4bbffcf9cf68532a5e1ba9ce3ecd2c813b 100644 --- a/aleksis/core/frontend/components/calendar/calendarOverview.graphql +++ b/aleksis/core/frontend/components/calendar/calendarOverview.graphql @@ -1,21 +1,26 @@ 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 - meta + 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/holiday/HolidayInlineList.vue b/aleksis/core/frontend/components/holiday/HolidayInlineList.vue index 07126079b04b793b54a223ec25c49100f01b4dd5..a7330f94db9b752c0fe80d5bb4fd6b1652a87b6e 100644 --- a/aleksis/core/frontend/components/holiday/HolidayInlineList.vue +++ b/aleksis/core/frontend/components/holiday/HolidayInlineList.vue @@ -5,24 +5,24 @@ import DateField from "../generic/forms/DateField.vue"; <template> <inline-c-r-u-d-list - :headers="headers" - :i18n-key="i18nKey" - create-item-i18n-key="holidays.create_holiday" - :gql-query="gqlQuery" - :gql-create-mutation="gqlCreateMutation" - :gql-patch-mutation="gqlPatchMutation" - :gql-delete-mutation="gqlDeleteMutation" - :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" - :default-item="defaultItem" - ref="crudList" + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="holidays.create_holiday" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + ref="crudList" > <template #holidayName.field="{ attrs, on, isCreate }"> <div aria-required="true"> <v-text-field - v-bind="attrs" - v-on="on" - required - :rules="required" + v-bind="attrs" + v-on="on" + required + :rules="required" ></v-text-field> </div> </template> @@ -33,11 +33,11 @@ import DateField from "../generic/forms/DateField.vue"; <template #dateStart.field="{ attrs, on, item, isCreate }"> <div aria-required="true"> <date-field - v-bind="attrs" - v-on="on" - :rules="required" - :max="item ? item.dateEnd : undefined" - @input="updateEndDate($event, item, isCreate)" + v-bind="attrs" + v-on="on" + :rules="required" + :max="item ? item.dateEnd : undefined" + @input="updateEndDate($event, item, isCreate)" ></date-field> </div> </template> @@ -48,11 +48,11 @@ import DateField from "../generic/forms/DateField.vue"; <template #dateEnd.field="{ attrs, on, item }"> <div aria-required="true"> <date-field - v-bind="attrs" - v-on="on" - required - :rules="required" - :min="item ? item.dateStart : undefined" + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" ></date-field> </div> </template> @@ -60,7 +60,13 @@ import DateField from "../generic/forms/DateField.vue"; </template> <script> -import {holidays, createHoliday, deleteHoliday, deleteHolidays, updateHolidays} from "./holiday.graphql"; +import { + holidays, + createHoliday, + deleteHoliday, + deleteHolidays, + updateHolidays, +} from "./holiday.graphql"; export default { name: "HolidayInlineList", @@ -96,21 +102,23 @@ export default { }, methods: { updateEndDate(newStartDate, item, isCreate) { - console.log("method called", item) + console.log("method called", item); let start = new Date(newStartDate); - console.log(start) + console.log(start); if (!item.endDate) { if (isCreate) { this.$refs.crudList.createModel.dateEnd = newStartDate; console.log("Changed of createmodel"); } else { - this.$refs.crudList.editableItems.find(holiday => holiday.id === item.id)[0].dateEnd = newStartDate; + this.$refs.crudList.editableItems.find( + (holiday) => holiday.id === item.id + )[0].dateEnd = newStartDate; console.log("Changed of editableitems"); } } else { console.log(item, newStartDate); } - } + }, }, }; </script> diff --git a/aleksis/core/frontend/components/holiday/holiday.graphql b/aleksis/core/frontend/components/holiday/holiday.graphql index 0b4742c3f304671c199c2ad9aa5f5cd95445b666..c76ff12f32b8f178188c94c2633e609e31854148 100644 --- a/aleksis/core/frontend/components/holiday/holiday.graphql +++ b/aleksis/core/frontend/components/holiday/holiday.graphql @@ -1,48 +1,48 @@ query holidays($orderBy: [String], $filters: JSONString) { - items: holidays(orderBy: $orderBy, filters: $filters) { - id - holidayName - dateStart - dateEnd - canEdit - canDelete - } + items: holidays(orderBy: $orderBy, filters: $filters) { + id + holidayName + dateStart + dateEnd + canEdit + canDelete + } } mutation createHoliday($input: CreateHolidayInput!) { - createHoliday(input: $input) { - holiday { - id - holidayName - dateStart - dateEnd - canEdit - canDelete - } + createHoliday(input: $input) { + holiday { + id + holidayName + dateStart + dateEnd + canEdit + canDelete } + } } mutation deleteHoliday($id: ID!) { - deleteHoliday(id: $id) { - ok - } + deleteHoliday(id: $id) { + ok + } } mutation deleteHolidays($ids: [ID]!) { - deleteHolidays(ids: $ids) { - deletionCount - } + deleteHolidays(ids: $ids) { + deletionCount + } } mutation updateHolidays($input: [BatchPatchHolidayInput]!) { - batchMutation: updateHolidays(input: $input) { - items: holidays { - id - holidayName - dateStart - dateEnd - canEdit - canDelete - } + batchMutation: updateHolidays(input: $input) { + items: holidays { + id + holidayName + dateStart + dateEnd + canEdit + canDelete } + } } diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 439c9aeb2db072dbdbf8c4d213721efc98cfda78..a477736639405a1005f8799ce09b6c88f45a95de 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -256,19 +256,22 @@ "new_version_available": "A new version of the app is available", "update": "Update" }, - "graphql": { - "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", + "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" + "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." }, "status": { "changes": "You have unsaved changes.", diff --git a/aleksis/core/frontend/mixins/calendarFeedDetails.js b/aleksis/core/frontend/mixins/calendarFeedDetails.js index 4d3e9beda841af3b84eadbcb8fab910525ca518c..0f729ba18355f7742d9080ea0188e8f3ba73e193 100644 --- a/aleksis/core/frontend/mixins/calendarFeedDetails.js +++ b/aleksis/core/frontend/mixins/calendarFeedDetails.js @@ -32,6 +32,10 @@ const calendarFeedDetailsMixin = { type: String, default: null, }, + calendarType: { + required: true, + type: String, + }, }, computed: { model: { diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 1c3d19d55061869cfa1d740af9160b2c643b0844..e8bc2e6a6232172e27c68daeb32d2410b8e0a04f 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -1061,14 +1061,15 @@ const routes = [ name: "invitations.accept_invite", }, { - path: "/calendar/overview", + path: "/calendar/", component: () => import("./components/calendar/CalendarOverview.vue"), name: "core.calendar_overview", meta: { inMenu: true, icon: "mdi-calendar", - titleKey: "calendar.menu_title_overview", - validators: [hasPersonValidator], + titleKey: "calendar.menu_title", + toolbarTitle: "calendar.menu_title", + permission: "core.view_calendar_feed_rule", }, }, ]; diff --git a/aleksis/core/migrations/0049_calendarevent.py b/aleksis/core/migrations/0049_calendarevent.py deleted file mode 100644 index d2f54fc4a353c982e5e5da6b0a879cf484d4dc71..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0049_calendarevent.py +++ /dev/null @@ -1,76 +0,0 @@ -# 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 deleted file mode 100644 index 09ea72fb0f44577aec4284343420dc7253e478cb..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0050_fix_amends.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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_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/migrations/0051_calendarevent_dates.py b/aleksis/core/migrations/0051_calendarevent_dates.py deleted file mode 100644 index e4b0bc3e763a0b00447f733d0c26f8c65373119f..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0051_calendarevent_dates.py +++ /dev/null @@ -1,92 +0,0 @@ -# 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 deleted file mode 100644 index ed2e75ba011261884c26e9e695f85595d0b63e91..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0052_holiday.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index 452e18e2a9bed5be3c42f19eb313b9a54df5a27d..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0053_alter_calendarevent_managers_alter_activity_site_and_more.py +++ /dev/null @@ -1,223 +0,0 @@ -# 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 deleted file mode 100644 index f2a98b3d712c9e3dcc2410ff398902370d8e8b30..0000000000000000000000000000000000000000 --- a/aleksis/core/migrations/0054_calendarevent_timezone.py +++ /dev/null @@ -1,51 +0,0 @@ -# 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 0da763a8e9c6ce049245e19e3dd8ac065f835abc..c6c19af0a5f58a105377bb2a36502b562056739d 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -14,7 +14,7 @@ from django.db import models 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.forms.models import ModelForm, ModelFormMetaclass, fields_for_model from django.http import HttpRequest, HttpResponse from django.utils.functional import classproperty, lazy from django.utils.translation import gettext as _ @@ -608,6 +608,11 @@ class RegistryObject: return cls.registered_objects_dict.get(name) +class ObjectAuthenticator(RegistryObject): + def authenticate(self, request, obj): + raise NotImplementedError() + + class CalendarEventMixin(RegistryObject): """Mixin for calendar feeds. @@ -741,7 +746,20 @@ class CalendarEventMixin(RegistryObject): 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.name != feed.__name__] + 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 3f6f6d915a4570855317de09939c980b25069949..4151d51b39d4cafac8ba91250a5ed64b8d4af50e 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1562,11 +1562,15 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel): @classmethod def value_end_datetime( cls, reference_object: "CalendarEvent", request - ) -> Union[datetime, date]: + ) -> 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) - return reference_object.date_end + timedelta(days=1) + 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]: diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 4c9758e16e3722c8775e50a11004c533366b3f05..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 @@ -502,3 +502,18 @@ class HolidayFeedColor(StringPreference): 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/schema/__init__.py b/aleksis/core/schema/__init__.py index 359b5164a52a21ca1f2cb9e39d35af2c5b9ae646..3279e7b12e31318a190c0d2fa738be8fc2dc1eaa 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -9,7 +9,6 @@ from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex -from ..mixins import CalendarEventMixin from ..models import ( CustomMenu, DynamicRoute, @@ -24,7 +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 CalendarType +from .calendar import CalendarBaseType, SetCalendarStatusMutation from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType from .dynamic_routes import DynamicRouteType @@ -98,16 +97,13 @@ 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()) school_terms = FilterOrderList(SchoolTermType) holidays = FilterOrderList(HolidayType) + calendar = graphene.Field(CalendarBaseType) def resolve_ping(root, info, payload) -> str: return payload @@ -146,7 +142,7 @@ class Query(graphene.ObjectType): return get_objects_for_user(info.context.user, "core.view_group", Group) @staticmethod - def resolve_group_by_id(root, info, id): + def resolve_group_by_id(root, info, id): # noqa group = Group.objects.filter(id=id) if len(group) != 1: @@ -228,12 +224,6 @@ 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") @@ -244,6 +234,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() @@ -269,6 +263,8 @@ class Mutation(graphene.ObjectType): delete_holidays = HolidayBatchDeleteMutation.Field() update_holidays = HolidayBatchPatchMutation.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 index 9aa2ad53227b1a3dca1f9b6ab0f0c4a629d21fe8..110e2cf826f26314b278bf293724712473a61ba8 100644 --- a/aleksis/core/schema/calendar.py +++ b/aleksis/core/schema/calendar.py @@ -1,14 +1,19 @@ 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() @@ -23,6 +28,9 @@ class CalendarEventType(ObjectType): 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 @@ -42,7 +50,7 @@ class CalendarEventType(ObjectType): return root.get("STATUS", "") def resolve_meta(root, info, **kwargs): - return root.get("X-META", {}) + return root.get("X-META", "{}") class CalendarFeedType(ObjectType): @@ -65,6 +73,8 @@ class CalendarType(ObjectType): url = graphene.String() + activated = graphene.Boolean() + def resolve_verbose_name(root, info, **kwargs): return root.get_verbose_name(info.context) @@ -79,3 +89,39 @@ class CalendarType(ObjectType): 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/schema/holiday.py b/aleksis/core/schema/holiday.py index 80d82045441ea16c9184a61ac8eed636e1c5daa6..b742b373e8f368ae2f6760f1361340b36a07fa7a 100644 --- a/aleksis/core/schema/holiday.py +++ b/aleksis/core/schema/holiday.py @@ -1,17 +1,21 @@ from graphene_django import DjangoObjectType -from graphene_django_cud.mutations import DjangoBatchDeleteMutation, DjangoBatchPatchMutation, DjangoCreateMutation +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) from ..models import Holiday from .base import ( DeleteMutation, DjangoFilterMixin, + PermissionBatchDeleteMixin, PermissionBatchPatchMixin, PermissionsTypeMixin, - PermissionBatchDeleteMixin, ) -class HolidayType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): +class HolidayType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = Holiday fields = ("id", "holiday_name", "date_start", "date_end") diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index b11680e95d9c06ba0d3d3eb041fcb9a3b28ea995..775dd392c5f79e3c43aeae19c3eea1247397457f 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -939,7 +939,7 @@ LOGGING["root"] = { } # Configure global log Format LOGGING["formatters"]["verbose"] = { - "format": "{asctime} {levelname} {name}[{process}]: {msg}", + "format": "{asctime} {levelname} {name}[{process}]: {message}", "style": "{", } # Add null handler for selective silencing diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 245fb296acfd8b56dd60c1695f11d60ddcac903b..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -42,6 +42,21 @@ urlpatterns = [ ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"), + path( + "o/<str:app_label>/<str:model>/<int:pk>/", + views.ObjectRepresentationView.as_view(), + name="object_representation_with_pk", + ), + path( + "o/<str:app_label>/<str:model>/", + views.ObjectRepresentationView.as_view(), + name="object_representation_with_model", + ), + path( + "o/", + views.ObjectRepresentationView.as_view(), + name="object_representation_anonymous", + ), path("", include("django_prometheus.urls")), path( "django/", @@ -387,6 +402,7 @@ urlpatterns = [ 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 26bcd0b83521c76581be52ca1272b672c0f01696..9329dda6192b554efa5e546a0160b5225e09da17 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -498,7 +498,6 @@ def get_ip(*args, **kwargs): 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"), ("meta", "x-meta"), ) diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 9bd7430e50fbbced00dae44cf9ded2a87ab96fca..f9e21e5b57472664e96bded0ebb471a11aff014b 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -101,6 +101,7 @@ from .mixins import ( AdvancedDeleteView, AdvancedEditView, CalendarEventMixin, + ObjectAuthenticator, SuccessNextMixin, ) from .models import ( @@ -1556,6 +1557,78 @@ class LoggingGraphQLView(GraphQLView): return result +class ObjectRepresentationView(View): + """View with unique URL to get a JSON representation of an object.""" + + def get_model(self, request: HttpRequest, app_label: str, model: str): + """Get the model by app label and model name.""" + try: + return apps.get_model(app_label, model) + except LookupError: + raise Http404() + + def get_object(self, request: HttpRequest, app_label: str, model: str, pk: int): + """Get the object by app label, model name and primary key.""" + if getattr(self, "model", None) is None: + self.model = self.get_model(request, app_label, model) + + try: + return self.model.objects.get(pk=pk) + except self.model.DoesNotExist: + raise Http404() + + def get( + self, + request: HttpRequest, + app_label: Optional[str] = None, + model: Optional[str] = None, + pk: Optional[int] = None, + *args, + **kwargs, + ) -> HttpResponse: + if app_label and model: + self.model = self.get_model(request, app_label, model) + else: + self.model = None + + if app_label and model and pk: + self.object = self.get_object(request, app_label, model, pk) + else: + self.object = None + + authenticators = request.GET.get("authenticators", "").split(",") + if authenticators == [""]: + authenticators = list(ObjectAuthenticator.registered_objects_dict.keys()) + self.authenticate(request, authenticators) + + if hasattr(self.object, "get_json"): + res = self.object.get_json(request) + else: + res = {"id": self.object.id} + res["_meta"] = { + "model": self.object._meta.model_name, + "app": self.object._meta.app_label, + } + + return JsonResponse(res) + + def authenticate(self, request: HttpRequest, authenticators: list[str]) -> bool: + """Authenticate the request against the given authenticators.""" + for authenticator in authenticators: + authenticator_class = ObjectAuthenticator.get_object_by_name(authenticator) + if not authenticator_class: + continue + obj = authenticator_class().authenticate(request, self.object) + if obj: + if self.object is None: + self.object = obj + elif obj != self.object: + raise BadRequest("Ambiguous objects identified") + return True + + raise PermissionDenied() + + class ICalFeedView(PermissionRequiredMixin, View): """View to generate an iCal feed for a calendar.""" @@ -1569,3 +1642,16 @@ class ICalFeedView(PermissionRequiredMixin, View): 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 0d341f37bba0b64b50d9281e7d3fe2f93540256c..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -259,7 +259,7 @@ export default defineConfig({ directoryIndex: null, navigateFallbackAllowlist: [ new RegExp( - "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$" ), ], additionalManifestEntries: [ @@ -274,7 +274,7 @@ export default defineConfig({ runtimeCaching: [ { urlPattern: new RegExp( - "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$" ), handler: "CacheFirst", }, diff --git a/pyproject.toml b/pyproject.toml index 971a392f511629e84beda415bc30fe9bb8d5dee7..63b60ab21d91ba67617a2014a8cf611e5822966a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,11 +125,11 @@ django-cte = "^1.1.5" pycountry = "^22.0.0" django-iconify = "^0.3" customidenticon = "^0.1.5" -graphene-django = ">=3.0.0, <=3.1.2" +graphene-django = ">=3.0.0, <=3.1.3" selenium = "^4.4.3" django-vite = "^2.0.2" -graphene-django-cud = "^0.10.0" -django-ical = "^1.8.3" +graphene-django-cud = "^0.11.0" +django-ical = "^1.9.2" django-recurrence = "^1.11.1" recurring-ical-events = "^2.0.2" django-timezone-field = "^5.0"