diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aacf652dcdd8f9eeed1b7c5b9624548d6a84a5a4..34003e8d69a1dfd1c6f77b1945403339397aa39a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,14 +31,18 @@ files accordingly (see docs for further instructions). As legacy pages are no longer themed, you should update them to the new frontend as soon as possible. +To make setting names consistent, the setting `auth.login.registration.unique_email` +was renamed to `auth.registration.unique_email`. + Added ~~~~~ -* Frontend for managing rooms. * Global calendar system * Calendar for birthdays of persons * Holiday model to track information about holidays. -* Frontend for managing holidays. +* Following management views were added: + * Rooms + * Holiday * [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. @@ -62,11 +66,15 @@ Added * Introduce .well-known urlpatterns for apps * Global school term select for limiting data to a specific school term. * [Dev] Notifications based on calendar alarms. +* CalDAV and CardDAV for syncing calendars and Persons read-only. Changed ~~~~~~~ -* Management of school terms was migrated to new frontend. +* Following management views were migrated to new frontend: + * School Terms + * Announcements + * OAuth Applications * [Dev] Child groups are exposed in the GraphQL type for groups. * Content width on different screen sizes is more consistent. * Room model icon changed from the default to a specific one. @@ -76,7 +84,9 @@ Changed * Replace all mentions of Redis with Valkey where possible * Show avatars of groups in all places. * Use new auth rate limiting settings +* Setting `auth.login.registration.unique_email` was renamed to `auth.registration.unique_email` * Bump Python version to 3.10 +* Adapt permission scheme for announcements to other permissions. Fixed ~~~~~ diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index ccaa9e948707884235309cb80406662a61acdb5d..2b512384a8011b0656568e37bbe69c0e13a9db15 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -62,7 +62,7 @@ class CoreConfig(AppConfig): from django.conf import settings # noqa # Autodiscover various modules defined by AlekSIS - autodiscover_modules("model_extensions", "form_extensions", "checks") + autodiscover_modules("model_extensions", "form_extensions", "checks", "util.dav_handler") personpreferencemodel = self.get_model("PersonPreferenceModel") grouppreferencemodel = self.get_model("GroupPreferenceModel") diff --git a/aleksis/core/checks.py b/aleksis/core/checks.py index 68ccb06bacae8e0b1dde9e14e4056e18e339e95e..d206c808dd273909e286d163260b80596267cb46 100644 --- a/aleksis/core/checks.py +++ b/aleksis/core/checks.py @@ -1,9 +1,11 @@ +from collections.abc import Iterable from typing import Optional import django.apps -from django.core.checks import Tags, Warning, register # noqa +from django.core.checks import Error, Tags, Warning, register # noqa from .mixins import ExtensibleModel, GlobalPermissionModel, PureDjangoModel +from .schema.base import BaseBatchCreateMutation, BaseBatchDeleteMutation, BaseBatchPatchMutation from .util.apps import AppConfig @@ -71,3 +73,28 @@ def check_app_models_base_class( ) return results + + +@register(Tags.security) +def check_all_mutations_with_permissions( + app_configs: Optional[django.apps.registry.Apps] = None, **kwargs +) -> list: + results = [] + for base_class in [BaseBatchCreateMutation, BaseBatchPatchMutation, BaseBatchDeleteMutation]: + for subclass in base_class.__subclasses__(): + if ( + not isinstance(subclass._meta.permissions, Iterable) + or not subclass._meta.permissions + ): + results.append( + Error( + f"Mutation {subclass.__name__} doesn't set required permission", + hint=( + "Ensure that the mutation is protected by setting the " + "permissions attribute in the mutation's Meta class." + ), + obj=subclass, + id="aleksis.core.E001", + ) + ) + return results diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 914f130492bbd79827f67abeb9599569dab0fc81..9c7ad504612e98fa496b5dd65d137733444b09db 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -1,5 +1,4 @@ from collections.abc import Sequence -from datetime import datetime, time from typing import Any, Callable from django import forms @@ -23,10 +22,8 @@ from material import Fieldset, Layout, Row from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm from .models import ( - Announcement, DashboardWidget, Group, - OAuthApplication, Person, PersonInvitation, ) @@ -35,7 +32,6 @@ from .registries import ( person_preferences_registry, site_preferences_registry, ) -from .util.auth_helpers import AppScopes from .util.core_helpers import get_site_preferences, queryset_rules_filter @@ -193,146 +189,6 @@ class EditGroupForm(SchoolTermRelatedExtensibleForm): } -class AnnouncementForm(ExtensibleForm): - """Form to create or edit an announcement in the frontend.""" - - valid_from = forms.DateTimeField(required=False) - valid_until = forms.DateTimeField(required=False) - - valid_from_date = forms.DateField(label=_("Date")) - valid_from_time = forms.TimeField(label=_("Time")) - - valid_until_date = forms.DateField(label=_("Date")) - valid_until_time = forms.TimeField(label=_("Time")) - - persons = forms.ModelMultipleChoiceField( - queryset=Person.objects.all(), - label=_("Persons"), - required=False, - widget=ModelSelect2MultipleWidget( - search_fields=[ - "first_name__icontains", - "last_name__icontains", - "short_name__icontains", - ], - attrs={"data-minimum-input-length": 0, "class": "browser-default"}, - ), - ) - groups = forms.ModelMultipleChoiceField( - queryset=None, - label=_("Groups"), - required=False, - widget=ModelSelect2MultipleWidget( - search_fields=[ - "name__icontains", - "short_name__icontains", - ], - attrs={"data-minimum-input-length": 0, "class": "browser-default"}, - ), - ) - - layout = Layout( - Fieldset( - _("From when until when should the announcement be displayed?"), - Row("valid_from_date", "valid_from_time", "valid_until_date", "valid_until_time"), - ), - Fieldset(_("Who should see the announcement?"), Row("groups", "persons")), - Fieldset(_("Write your announcement:"), "title", "description"), - Fieldset(_("Set a priority"), "priority"), - ) - - def __init__(self, *args, **kwargs): - if "instance" not in kwargs: - # Default to today and whole day for new announcements - kwargs["initial"] = { - "valid_from_date": datetime.now(), - "valid_from_time": time(0, 0), - "valid_until_date": datetime.now(), - "valid_until_time": time(23, 59), - } - else: - announcement = kwargs["instance"] - - # Fill special fields from given announcement instance - kwargs["initial"] = { - "valid_from_date": announcement.valid_from.date(), - "valid_from_time": announcement.valid_from.time(), - "valid_until_date": announcement.valid_until.date(), - "valid_until_time": announcement.valid_until.time(), - "groups": announcement.get_recipients_for_model(Group), - "persons": announcement.get_recipients_for_model(Person), - } - - super().__init__(*args, **kwargs) - - self.fields["groups"].queryset = Group.objects.for_current_school_term_or_all() - - def clean(self): - data = super().clean() - - # Combine date and time fields into datetime objects - valid_from = datetime.combine(data["valid_from_date"], data["valid_from_time"]) - valid_until = datetime.combine(data["valid_until_date"], data["valid_until_time"]) - - # Sanity check validity range - if valid_until < datetime.now(): - raise ValidationError( - _("You are not allowed to create announcements which are only valid in the past.") - ) - elif valid_from > valid_until: - raise ValidationError( - _("The from date and time must be earlier then the until date and time.") - ) - - # Inject real time data if all went well - data["valid_from"] = valid_from - data["valid_until"] = valid_until - - # Ensure at least one group or one person is set as recipient - if "groups" not in data and "persons" not in data: - raise ValidationError(_("You need at least one recipient.")) - - # Unwrap all recipients into single user objects and generate final list - data["recipients"] = [] - data["recipients"] += data.get("groups", []) - data["recipients"] += data.get("persons", []) - - return data - - def save(self, _=False): - # Save announcement, respecting data injected in clean() - if self.instance is None: - self.instance = Announcement() - self.instance.valid_from = self.cleaned_data["valid_from"] - self.instance.valid_until = self.cleaned_data["valid_until"] - self.instance.title = self.cleaned_data["title"] - self.instance.description = self.cleaned_data["description"] - self.instance.priority = self.cleaned_data["priority"] - self.instance.save() - - # Save recipients - self.instance.recipients.all().delete() - for recipient in self.cleaned_data["recipients"]: - self.instance.recipients.create(recipient=recipient) - self.instance.save() - - return self.instance - - class Meta: - model = Announcement - fields = [ - "valid_from_date", - "valid_from_time", - "valid_until_date", - "valid_until_time", - "groups", - "persons", - "title", - "description", - "priority", - ] - - class ChildGroupsForm(forms.Form): """Inline form for group editing to select child groups.""" @@ -815,28 +671,6 @@ class ListActionForm(ActionForm): self.fields["selected_objects"].choices = self._get_choices() -class OAuthApplicationForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["allowed_scopes"].widget = forms.SelectMultiple( - choices=list(AppScopes().get_all_scopes().items()) - ) - - class Meta: - model = OAuthApplication - fields = ( - "name", - "icon", - "client_id", - "client_secret", - "client_type", - "algorithm", - "allowed_scopes", - "redirect_uris", - "skip_authorization", - ) - - class MaintenanceModeForm(forms.Form): maintenance_mode = forms.BooleanField( required=False, diff --git a/aleksis/core/frontend/components/announcements/Announcements.vue b/aleksis/core/frontend/components/announcements/Announcements.vue new file mode 100644 index 0000000000000000000000000000000000000000..3529992700215d704fd869cd090cc7163c3deff7 --- /dev/null +++ b/aleksis/core/frontend/components/announcements/Announcements.vue @@ -0,0 +1,208 @@ +<script setup> +import CRUDList from "../generic/CRUDList.vue"; +import DateTimeField from "../generic/forms/DateTimeField.vue"; +import GroupChip from "../group/GroupChip.vue"; +import GroupField from "../generic/forms/GroupField.vue"; +import PersonChip from "../person/PersonChip.vue"; +import PersonField from "../generic/forms/PersonField.vue"; +import PositiveSmallIntegerField from "../generic/forms/PositiveSmallIntegerField.vue"; +</script> + +<template> + <c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + item-attribute="title" + :enable-edit="true" + > + <template #validFrom="{ item }"> + {{ $d($parseISODate(item.validFrom), "shortDateTime") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #validFrom.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-time-field + dense + hide-details="auto" + v-bind="attrs" + v-on="on" + required + :max="item.validUntil" + :rules="$rules().required.build()" + /> + </div> + </template> + + <template #validUntil="{ item }"> + {{ $d($parseISODate(item.validUntil), "shortDateTime") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #validUntil.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-time-field + dense + hide-details="auto" + v-bind="attrs" + v-on="on" + required + :min="$parseISODate(item.validFrom).plus({ minutes: 1 }).toISO()" + :rules="$rules().required.build()" + /> + </div> + </template> + + <template #recipientGroups="{ item }"> + <group-chip + v-for="group in item.recipientGroups" + :key="group.id" + :group="group" + format="short" + class="mr-1" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #recipientGroups.field="{ attrs, on, item }"> + <div :aria-required="item.recipientPersons?.length === 0"> + <group-field + v-bind="attrs" + v-on="on" + :required="item.recipientPersons?.length === 0" + :rules=" + item.recipientPersons?.length === 0 + ? $rules().isNonEmpty.build() + : [] + " + multiple + /> + </div> + </template> + + <template #recipientPersons="{ item }"> + <person-chip + v-for="person in item.recipientPersons" + :key="person.id" + :person="person" + class="mr-1" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #recipientPersons.field="{ attrs, on, item }"> + <div :aria-required="item.recipientGroups?.length === 0"> + <person-field + v-bind="attrs" + v-on="on" + :required="item.recipientGroups?.length === 0" + :rules=" + item.recipientGroups?.length === 0 + ? $rules().isNonEmpty.build() + : [] + " + multiple + /> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #title.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + required + :rules="$rules().required.build()" + /> + </div> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #description.field="{ attrs, on }"> + <v-textarea rows="1" auto-grow v-bind="attrs" v-on="on" /> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #priority.field="{ attrs, on }"> + <positive-small-integer-field v-bind="attrs" v-on="on" /> + </template> + </c-r-u-d-list> +</template> + +<script> +import { + announcements, + createAnnouncements, + deleteAnnouncements, + patchAnnouncements, +} from "./announcements.graphql"; + +import formRulesMixin from "../../mixins/formRulesMixin"; +import { DateTime } from "luxon"; + +export default { + name: "Announcements", + mixins: [formRulesMixin], + data() { + return { + headers: [ + { + text: this.$t("announcement.valid_from"), + value: "validFrom", + cols: 6, + }, + { + text: this.$t("announcement.valid_until"), + value: "validUntil", + cols: 6, + }, + { + text: this.$t("announcement.recipient_groups"), + value: "recipientGroups", + cols: 6, + }, + { + text: this.$t("announcement.recipient_persons"), + value: "recipientPersons", + cols: 6, + }, + { + text: this.$t("announcement.title"), + value: "title", + cols: 12, + }, + { + text: this.$t("announcement.description"), + value: "description", + cols: 12, + }, + { + text: this.$t("announcement.priority"), + value: "priority", + cols: 5, + }, + ], + i18nKey: "announcement", + gqlQuery: announcements, + gqlCreateMutation: createAnnouncements, + gqlPatchMutation: patchAnnouncements, + gqlDeleteMutation: deleteAnnouncements, + defaultItem: { + validFrom: DateTime.now() + .startOf("minute") + .toISO({ suppressSeconds: true }), + validUntil: DateTime.now() + .startOf("minute") + .plus({ hours: 1 }) + .toISO({ suppressSeconds: true }), + recipientGroups: [], + recipientPersons: [], + title: "", + description: "", + }, + }; + }, +}; +</script> diff --git a/aleksis/core/frontend/components/announcements/announcements.graphql b/aleksis/core/frontend/components/announcements/announcements.graphql new file mode 100644 index 0000000000000000000000000000000000000000..72ad657079c3680b6b68cd28c60d5846a444ecdd --- /dev/null +++ b/aleksis/core/frontend/components/announcements/announcements.graphql @@ -0,0 +1,46 @@ +fragment announcementFields on AnnouncementType { + id + validFrom + validUntil + title + description + priority + recipientGroups { + id + shortName + } + recipientPersons { + id + fullName + } + canDelete + canEdit +} + +query announcements { + items: announcements { + ...announcementFields + } +} + +mutation createAnnouncements($input: [BatchCreateAnnouncementInput]!) { + createAnnouncements(input: $input) { + items: announcements { + ...announcementFields + } + } +} + +mutation deleteAnnouncements($ids: [ID]!) { + deleteAnnouncements(ids: $ids) { + deletionCount + } +} + +mutation patchAnnouncements($input: [BatchPatchAnnouncementInput]!) { + patchAnnouncements(input: $input) { + items: announcements { + ...announcementFields + } + } +} diff --git a/aleksis/core/frontend/components/calendar/Calendar.vue b/aleksis/core/frontend/components/calendar/Calendar.vue index d8fcdfe39a6e5f46568315c94c5b32cd3bdaf112..511af38d555dc5cac5d9226646ca7ac480f28858 100644 --- a/aleksis/core/frontend/components/calendar/Calendar.vue +++ b/aleksis/core/frontend/components/calendar/Calendar.vue @@ -217,7 +217,10 @@ export default { .flatMap((cf) => cf.events.map((event) => { const start = this.$parseISODate(event.start); - const end = this.$parseISODate(event.end); + let end = this.$parseISODate(event.end); + if (event.allDay) { + end = end.minus({ days: 1 }); + } return { ...event, category: cf.verboseName, diff --git a/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue b/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue index ba1f24596736d188343f9dbfbd561ee1b1f1ff3a..cb418b929afd3cab1fabbb3ce072b7603c4a6b2e 100644 --- a/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue +++ b/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue @@ -61,7 +61,7 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #datetimeStart.field="{ attrs, on }"> + <template #datetimeStart.field="{ attrs, on, item }"> <v-slide-y-transition appear> <div aria-required="true"> <date-time-field @@ -70,13 +70,14 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v v-bind="attrs" v-on="on" required + :max="item.datetimeEnd" /> </div> </v-slide-y-transition> </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #datetimeEnd.field="{ attrs, on }"> + <template #datetimeEnd.field="{ attrs, on, item }"> <v-slide-y-transition appear> <div aria-required="true"> <date-time-field @@ -85,6 +86,9 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v v-bind="attrs" v-on="on" required + :min=" + $parseISODate(item.datetimeStart).plus({ minutes: 1 }).toISO() + " /> </div> </v-slide-y-transition> @@ -245,8 +249,13 @@ export default { datetimeEnd: item.fullDay ? undefined : item.datetimeEnd, dateStart: item.fullDay ? item.dateStart : undefined, dateEnd: item.fullDay ? item.dateEnd : undefined, - recurrences: item.recurring === false ? "" : item.recurrences, - timezone: DateTime.local().zoneName, + ...(item.recurring + ? { + // Add clients timezone only if item is recurring + timezone: DateTime.local().zoneName, + recurrences: item.recurrences, + } + : {}), persons: this.checkPermission( "core.create_personal_event_with_invitations_rule", ) diff --git a/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql b/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql index 695c0719079a86ad3c428f526490a1d2c1b6ca76..a22bc0a465f0b2a64610a17a6f5693c6d509ab89 100644 --- a/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql +++ b/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql @@ -1,25 +1,30 @@ +fragment personalEventFields on PersonalEventType { + id + title + description + location + + datetimeStart + datetimeEnd + dateStart + dateEnd + timezone + recurrences + + persons { + id + fullName + } + groups { + id + shortName + } +} + mutation createPersonalEvents($input: [BatchCreatePersonalEventInput]!) { createPersonalEvents(input: $input) { items: personalEvents { - id - title - description - location - - datetimeStart - datetimeEnd - dateStart - dateEnd - recurrences - - persons { - id - fullName - } - groups { - id - shortName - } + ...personalEventFields } } } @@ -33,25 +38,7 @@ mutation deletePersonalEvents($ids: [ID]!) { mutation updatePersonalEvents($input: [BatchPatchPersonalEventInput]!) { updatePersonalEvents(input: $input) { items: personalEvents { - id - title - description - location - - datetimeStart - datetimeEnd - dateStart - dateEnd - recurrences - - persons { - id - fullName - } - groups { - id - shortName - } + ...personalEventFields } } } diff --git a/aleksis/core/frontend/components/generic/CRUDBar.vue b/aleksis/core/frontend/components/generic/CRUDBar.vue index 9b28ea8ce42464f96de1927ee11ef6621e146f0e..086067eaf39fe04e470947b6a4d543c0ab92f839 100644 --- a/aleksis/core/frontend/components/generic/CRUDBar.vue +++ b/aleksis/core/frontend/components/generic/CRUDBar.vue @@ -93,10 +93,12 @@ editItemI18nKey: $attrs['edit-item-i18n-key'], itemId: $attrs['item-id'], gqlDataKey: gqlDataKey, + minimalPatch: $attrs['minimal-patch'], }" :on="{ input: (i) => (i ? (createMode = true) : null), loading: (loading) => handleLoading(loading), + save: (items) => $emit('save', items), }" :create-mode="createMode" :form-field-slot-name="fieldSlot" @@ -118,7 +120,9 @@ :item-id="$attrs['item-id']" :force-model-item-update="!isCreate" :gql-data-key="gqlDataKey" + :minimal-patch="$attrs['minimal-patch']" @loading="handleLoading($event)" + @save="$emit('save', $event)" > <template #activator="{ props }"> <create-button @@ -366,7 +370,7 @@ export default { default: false, }, }, - emits: ["search", "selectable", "selection", "deletable", "mode"], + emits: ["search", "selectable", "selection", "deletable", "mode", "create"], data() { return { // Use create component for creation @@ -445,6 +449,11 @@ export default { }, // CRUD menus handleCreate() { + /** + * Emitted to signal opening of create dialog + */ + this.$emit("create"); + this.isCreate = true; this.createMode = true; }, diff --git a/aleksis/core/frontend/components/generic/CRUDList.vue b/aleksis/core/frontend/components/generic/CRUDList.vue index 85fd418a63a692e7fbeae6c11175cf59e52e46d6..e028b70b40e1c7954c2143c0b35de164d1551485 100644 --- a/aleksis/core/frontend/components/generic/CRUDList.vue +++ b/aleksis/core/frontend/components/generic/CRUDList.vue @@ -30,6 +30,8 @@ ref="bar" :gql-order-by="orderBy" @mode="handleMode" + @create="$emit('create')" + @save="$emit('save', $event)" @loading="handleLoading" @rawItems="$emit('rawItems', $event)" @items="handleItems" @@ -39,6 +41,7 @@ :selection="showSelect ? selection : []" @selection="selection = $event" @deletable="showDelete = true" + minimal-patch > <template #title="{ attrs, on }"> <slot name="title" :attrs="attrs" :on="on" /> diff --git a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue index 79ed5e397986ae4b27fa4767433997664e6f6024..ad23fb520aca40b77fddd123ac123a4875d6bf3d 100644 --- a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue +++ b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue @@ -25,7 +25,7 @@ v-on="$listeners" :valid.sync="valid" @loading="handleLoading" - @save="dialog = false" + @save="handleSave" @cancel="dialog = false" > <template v-for="(_, slot) of $scopedSlots" #[slot]="scope" @@ -66,7 +66,7 @@ export default { MobileFullscreenDialog, }, mixins: [openableDialogMixin, objectFormPropsMixin, loadingMixin], - emits: ["cancel"], + emits: ["cancel", "save"], computed: { dialog: { get() { @@ -81,6 +81,10 @@ export default { close() { this.dialog = false; }, + handleSave(items) { + this.dialog = false; + this.$emit("save", items); + }, }, data() { return { diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue index 5178574b7e02bb236fd24edf0793aaa48bf2e1fb..bd990458f3509672e2e402153900fe0f91e12abb 100644 --- a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue +++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue @@ -49,22 +49,12 @@ export default { required: false, default: undefined, }, - minDate: { + min: { type: String, required: false, default: undefined, }, - maxDate: { - type: String, - required: false, - default: undefined, - }, - minTime: { - type: String, - required: false, - default: undefined, - }, - maxTime: { + max: { type: String, required: false, default: undefined, @@ -111,6 +101,32 @@ export default { this.dateTime = newDateTime.toISO(); }, }, + minDT() { + return this.$parseISODate(this.min); + }, + minDate() { + return this.minDT.toISODate(); + }, + minTime() { + if (this.dateTime.hasSame(this.minDT, "day")) { + return this.minDT.toFormat("HH:mm"); + } else { + return undefined; + } + }, + maxDT() { + return this.$parseISODate(this.max); + }, + maxDate() { + return this.maxDT.toISODate(); + }, + maxTime() { + if (this.dateTime.hasSame(this.maxDT, "day")) { + return this.maxDT.toFormat("HH:mm"); + } else { + return undefined; + } + }, }, watch: { value(newValue) { diff --git a/aleksis/core/frontend/components/oauth/OAuthApplications.vue b/aleksis/core/frontend/components/oauth/OAuthApplications.vue new file mode 100644 index 0000000000000000000000000000000000000000..7ff6fadb17fe09412b3c37adf906cdacacb1550b --- /dev/null +++ b/aleksis/core/frontend/components/oauth/OAuthApplications.vue @@ -0,0 +1,276 @@ +<script setup> +import CRUDList from "../generic/CRUDList.vue"; +</script> + +<template> + <c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :default-item="defaultItem" + :enable-edit="true" + @create="$apollo.queries.initOauthApplication.skip = false" + @save="handleSave" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <v-text-field + v-bind="attrs" + v-on="on" + filled + required + :rules="$rules().required.build()" + /> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #clientId.field="{ attrs, on }"> + <v-text-field v-bind="attrs" v-on="on" filled readonly /> + </template> + + <template #clientSecret="{ item }"> + <div class="client_secret"> + {{ item.clientSecret }} + </div> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #clientSecret.field="{ attrs, on, isCreate }"> + <v-text-field + v-bind="attrs" + v-on="on" + filled + readonly + :hint="$t('oauth.application.client_secret_hint')" + persistent-hint + :class="{ 'd-none': !isCreate }" + /> + </template> + + <template #clientType="{ item }"> + {{ lookupChoiceText(clientTypeChoices, item.clientType) }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #clientType.field="{ attrs, on }"> + <v-autocomplete + v-bind="attrs" + v-on="on" + :items="clientTypeChoices" + required + :rules="$rules().required.build()" + /> + </template> + + <template #algorithm="{ item }"> + {{ lookupChoiceText(algorithmChoices, item.algorithm) }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #algorithm.field="{ attrs, on }"> + <v-autocomplete + v-bind="attrs" + v-on="on" + :items="algorithmChoices" + required + :rules="$rules().required.build()" + /> + </template> + + <template #allowedScopes="{ item }"> + <v-chip v-for="scope in item.allowedScopes" :key="scope"> + {{ + lookupChoiceText(oauthScopes, scope, "name", "description") + }} </v-chip + > + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #allowedScopes.field="{ attrs, on }"> + <v-autocomplete + v-bind="attrs" + v-on="on" + hide-no-data + multiple + filled + :items="oauthScopes" + item-text="description" + item-value="name" + /> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #redirectUris.field="{ attrs, on }"> + <v-text-field + v-bind="attrs" + v-on="on" + filled + :hint="$t('oauth.application.redirect_uris_hint')" + persistent-hint + /> + </template> + + <template #skipAuthorization="{ item }"> + <v-switch + :input-value="item.skipAuthorization" + disabled + inset + :false-value="false" + :true-value="true" + /> + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #skipAuthorization.field="{ attrs, on }"> + <v-switch + v-bind="attrs" + v-on="on" + inset + :false-value="false" + :true-value="true" + /> + </template> + </c-r-u-d-list> +</template> + +<script> +import { + oauthApplications, + initOauthApplication, + createOauthApplications, + patchOauthApplications, + deleteOauthApplications, + gqlOauthScopes, +} from "./oauthApplications.graphql"; + +import formRulesMixin from "../../mixins/formRulesMixin"; + +export default { + name: "OAuthApplications", + mixins: [formRulesMixin], + data() { + return { + headers: [ + { + text: this.$t("oauth.application.name"), + value: "name", + cols: 12, + }, + // { + // text: this.$t("oauth.application.icon"), + // value: "icon", + // cols: 12, + // }, + { + text: this.$t("oauth.application.client_id"), + value: "clientId", + cols: 12, + hidden: true, + }, + { + text: this.$t("oauth.application.client_secret"), + value: "clientSecret", + cols: 12, + hidden: true, + }, + { + text: this.$t("oauth.application.client_type"), + value: "clientType", + cols: 12, + }, + // Not in orig show, but in orig create + { + text: this.$t("oauth.application.algorithm"), + value: "algorithm", + cols: 12, + }, + { + text: this.$t("oauth.application.allowed_scopes"), + value: "allowedScopes", + cols: 12, + }, + { + text: this.$t("oauth.application.redirect_uris"), + value: "redirectUris", + cols: 12, + }, + { + text: this.$t("oauth.application.skip_authorization"), + value: "skipAuthorization", + cols: 12, + }, + ], + i18nKey: "oauth.application", + gqlQuery: oauthApplications, + gqlCreateMutation: createOauthApplications, + gqlPatchMutation: patchOauthApplications, + gqlDeleteMutation: deleteOauthApplications, + clientTypeChoices: [ + { + text: this.$t("oauth.application.client_type_confidential"), + value: "CONFIDENTIAL", + }, + { + text: this.$t("oauth.application.client_type_public"), + value: "PUBLIC", + }, + ], + algorithmChoices: [ + { + text: this.$t("oauth.application.algorithm_no_oidc"), + value: "A_", + }, + { + text: this.$t("oauth.application.algorithm_rsa"), + value: "RS256", + }, + { + text: this.$t("oauth.application.algorithm_hmac"), + value: "HS256", + }, + ], + }; + }, + computed: { + defaultItem() { + return { + name: "", + // icon: "", + clientId: this.initOauthApplication?.clientId, + clientSecret: this.initOauthApplication?.clientSecret, + clientType: "", + algorithm: "A_", + allowedScopes: "", + redirectUris: "", + skipAuthorization: false, + }; + }, + }, + apollo: { + initOauthApplication: { + query: initOauthApplication, + skip: true, + }, + oauthScopes: { + query: gqlOauthScopes, + }, + }, + methods: { + handleSave() { + this.$apollo.queries.initOauthApplication.skip = true; + this.queries.initOauthApplication.refetch(); + }, + lookupChoiceText(choices, value, valueKey = "value", textKey = "text") { + return (choices.find((choice) => choice[valueKey] === value) || { + [textKey]: value, + })[textKey]; + }, + }, +}; +</script> + +<style scoped> +.client_secret { + overflow: hidden; + text-overflow: ellipsis; + max-width: 20em; +} +</style> diff --git a/aleksis/core/frontend/components/oauth/oauthApplications.graphql b/aleksis/core/frontend/components/oauth/oauthApplications.graphql new file mode 100644 index 0000000000000000000000000000000000000000..6523f3ad2b28e18ece9ee90923e479f406a079a2 --- /dev/null +++ b/aleksis/core/frontend/components/oauth/oauthApplications.graphql @@ -0,0 +1,56 @@ +fragment oauthApplicationFields on OAuthApplicationType { + id + name + # icon + clientId + clientSecret + clientType + algorithm + allowedScopes + redirectUris + skipAuthorization + canDelete + canEdit +} + +query oauthApplications { + items: oauthApplications { + ...oauthApplicationFields + } +} + +query initOauthApplication { + initOauthApplication { + clientId + clientSecret + } +} + +query gqlOauthScopes { + oauthScopes { + name + description + } +} + +mutation createOauthApplications($input: [BatchCreateOAuthApplicationInput]!) { + createOauthApplications(input: $input) { + items: oAuthApplications { + ...oauthApplicationFields + } + } +} + +mutation deleteOauthApplications($ids: [ID]!) { + deleteOauthApplications(ids: $ids) { + deletionCount + } +} + +mutation patchOauthApplications($input: [BatchPatchOAuthApplicationInput]!) { + patchOauthApplications(input: $input) { + items: oAuthApplications { + ...oauthApplicationFields + } + } +} diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json index 54b0bc72769e3892de46a4660adc49b0a87b380e..d9d41623cbe1da552cf74355f4b204fe44eb957c 100644 --- a/aleksis/core/frontend/messages/de.json +++ b/aleksis/core/frontend/messages/de.json @@ -95,7 +95,8 @@ "select_all": "Alle auswählen", "stop_editing": "Bearbeiten beenden", "title": "Aktionen", - "update": "Aktualisieren" + "update": "Aktualisieren", + "next": "Weiter" }, "administration": { "backend_admin": { diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 36233f0f775e15c13e5634b2398b544dec8f36fb..fb6d5da145757e435dfa2197a5027d5652d89b29 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -93,6 +93,7 @@ "stop_editing": "Stop editing", "title": "Actions", "update": "Update", + "next": "Next", "type_to_search": "Start typing to search" }, "administration": { @@ -109,8 +110,14 @@ }, "announcement": { "menu_title": "Announcements", - "title": "Announcement", - "title_plural": "Announcements" + "title_plural": "Announcements", + "valid_from": "Valid from", + "valid_until": "Valid until", + "recipient_groups": "Recipient groups", + "recipient_persons": "Recipient persons", + "title": "Title", + "description": "Description", + "priority": "Priority" }, "base": { "about_aleksis": "About AlekSIS® — The Free School Information System", @@ -255,7 +262,23 @@ "application": { "menu_title": "OAuth Applications", "title": "OAuth Application", - "title_plural": "OAuth Applications" + "title_plural": "OAuth Applications", + "name": "Name", + "icon": "Icon", + "client_id": "Client ID", + "client_secret": "Client Secret", + "client_secret_hint": "Hashed on save. Copy it now if this is a new secret.", + "client_type": "Client Type", + "client_type_confidential": "confidential", + "client_type_public": "public", + "algorithm": "Algorithm", + "algorithm_no_oidc": "No OIDC support", + "algorithm_rsa": "RSA with SHA-2 256", + "algorithm_hmac": "HMAC with SHA-2 256", + "allowed_scopes": "Allowed Scopes", + "redirect_uris": "Redirect URIs", + "redirect_uris_hint": "Allowed URIs list. Space seperated.", + "skip_authorization": "Skip Authorization" }, "authorized_application": { "access_since": "Access since {date}", diff --git a/aleksis/core/frontend/mixins/formRulesMixin.js b/aleksis/core/frontend/mixins/formRulesMixin.js index 85a6f3ef2564c09bf899f8cdb117171cb447efa3..d0bc362d9180c446110f0366b5b3b5855af6d122 100644 --- a/aleksis/core/frontend/mixins/formRulesMixin.js +++ b/aleksis/core/frontend/mixins/formRulesMixin.js @@ -94,6 +94,12 @@ export default { ); return this; }, + get isNonEmpty() { + this._rules.push( + (value) => value.length > 0 || mixin.$t("forms.errors.required"), + ); + return this; + }, get isEmail() { const emailRegex = // eslint-disable-next-line no-control-regex diff --git a/aleksis/core/frontend/mixins/objectFormMixin.js b/aleksis/core/frontend/mixins/objectFormMixin.js index ca6b36d70ea6f0a8bf57cb180ba9bd3b9c66368f..ffc62d476dd1f19db08539989d75fa952c44c587 100644 --- a/aleksis/core/frontend/mixins/objectFormMixin.js +++ b/aleksis/core/frontend/mixins/objectFormMixin.js @@ -8,12 +8,17 @@ export default { valid: false, firstInitDone: false, itemModel: {}, + editPatch: {}, }; }, methods: { dynamicSetter(item, fieldName) { return (value) => { this.$set(item, fieldName, value); + if (this.minimalPatch && !this.isCreate) { + this.$set(this.editPatch, this.itemId, item.id); + this.$set(this.editPatch, fieldName, value); + } }; }, buildExternalSetter(item) { @@ -54,6 +59,7 @@ export default { this.itemModel = JSON.parse( JSON.stringify(this.isCreate ? this.defaultItem : this.editItem), ); + this.editPatch = {}; }, updateModel() { // Only update the model if the dialog is hidden or has just been mounted @@ -62,7 +68,11 @@ export default { } }, submit() { - this.createOrPatch([this.itemModel]); + if (this.minimalPatch && !this.isCreate) { + this.createOrPatch([this.editPatch]); + } else { + this.createOrPatch([this.itemModel]); + } }, }, mounted() { @@ -81,9 +91,17 @@ export default { ? this.$t(this.createItemI18nKey) : this.$t(this.editItemI18nKey); }, + internalValid() { + return ( + !!this.valid && + (this.minimalPatch && !this.isCreate + ? Object.keys(this.editPatch).length > 0 + : true) + ); + }, }, watch: { - valid(valid) { + internalValid(valid) { this.$emit("update:valid", valid); }, }, diff --git a/aleksis/core/frontend/mixins/objectFormPropsMixin.js b/aleksis/core/frontend/mixins/objectFormPropsMixin.js index bbef57b7b980b33c2d8c68b0ffc75ff7889dd362..67cb89eb45df87455fe40ac43b0b780f01101fd7 100644 --- a/aleksis/core/frontend/mixins/objectFormPropsMixin.js +++ b/aleksis/core/frontend/mixins/objectFormPropsMixin.js @@ -70,6 +70,15 @@ export default { required: false, default: false, }, + /** + * When used for editing send a only the changes not the whole editObject. + * Mimics the behaviour of the InlineCRUDList. + */ + minimalPatch: { + type: Boolean, + required: false, + default: false, + }, }, computed: { objectFormProps() { @@ -83,6 +92,7 @@ export default { defaultItem: this.defaultItem, editItem: this.editItem, forceModelItemUpdate: this.forceModelItemUpdate, + minimalPatch: this.minimalPatch, }; }, }, diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 1fdf7b78efd5395547cbd1520a66146f2cfaf288..048272f56950762b065df8572323b834a97a1733 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -226,10 +226,7 @@ const routes = [ }, { path: "/announcements/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/announcements/Announcements.vue"), name: "core.announcements", meta: { inMenu: true, @@ -239,30 +236,6 @@ const routes = [ permission: "core.view_announcements_rule", }, }, - { - path: "/announcements/create/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.addAnnouncement", - }, - { - path: "/announcements/edit/:id(\\d+)/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.editAnnouncement", - }, - { - path: "/announcements/delete/:id(\\d+)/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.deleteAnnouncement", - }, { path: "/holidays/", component: () => import("./components/holiday/HolidayInlineList.vue"), @@ -502,13 +475,9 @@ const routes = [ }, name: "core.assignPermission", }, - { path: "/oauth/applications/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/oauth/OAuthApplications.vue"), name: "core.oauthApplications", meta: { inMenu: true, @@ -517,38 +486,6 @@ const routes = [ permission: "core.view_oauthapplications_rule", }, }, - { - path: "/oauth/applications/register/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.registerOauthApplication,", - }, - { - path: "/oauth/applications/:pk(\\d+)", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.oauthApplication", - }, - { - path: "/oauth/applications/:pk(\\d+)/delete/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.delete_oauth2_application,", - }, - { - path: "/oauth/applications/:pk(\\d+)/edit/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.editOauthApplication", - }, { path: "/admin/", component: () => import("./components/LegacyBaseTemplate.vue"), diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index fccc79daae0e5267ef7912a8ae62850dc00c2a20..8ab80c9c4d6bda706b80f5477738bae7e727ab19 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -47,12 +47,20 @@ class PolymorphicBaseManager(AlekSISBaseManagerWithoutMigrations, PolymorphicMan """Default manager for extensible, polymorphic models.""" -class RecurrencePolymorphicQuerySet(PolymorphicQuerySet, CTEQuerySet): +class CalendarEventMixinQuerySet(CTEQuerySet): pass -class RecurrencePolymorphicManager(PolymorphicBaseManager, RecurrenceManager): - queryset_class = RecurrencePolymorphicQuerySet +class CalendarEventQuerySet(PolymorphicQuerySet, CalendarEventMixinQuerySet): + pass + + +class CalendarEventMixinManager(RecurrenceManager): + queryset_class = CalendarEventMixinQuerySet + + +class CalendarEventManager(PolymorphicBaseManager, CalendarEventMixinManager): + queryset_class = CalendarEventQuerySet class DateRangeQuerySetMixin: @@ -211,7 +219,7 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager): return super().get_queryset().filter(widget_id__in=dashboard_widget_pks) -class HolidayQuerySet(DateRangeQuerySetMixin, RecurrencePolymorphicQuerySet): +class HolidayQuerySet(DateRangeQuerySetMixin, CalendarEventQuerySet): """QuerySet with custom query methods for holidays.""" def get_all_days(self) -> list[date]: @@ -222,5 +230,5 @@ class HolidayQuerySet(DateRangeQuerySetMixin, RecurrencePolymorphicQuerySet): return holiday_days -class HolidayManager(RecurrencePolymorphicManager): +class HolidayManager(CalendarEventManager): queryset_class = HolidayQuerySet diff --git a/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py b/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py new file mode 100644 index 0000000000000000000000000000000000000000..34e54bc450a4cba17555101709d0ab4676ca0290 --- /dev/null +++ b/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +from django.db.models import F, Q + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0070_oauth_token_checksum'), + ] + + operations = [ + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.CheckConstraint(check=Q(datetime_end__gte=F('datetime_start')), + name="datetime_start_before_end" + ), + ), + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.CheckConstraint(check=Q(date_end__gte=F('date_start')), + name="date_start_before_end" + ), + ), + ] diff --git a/aleksis/core/migrations/0072_birthdayevent_view.py b/aleksis/core/migrations/0072_birthdayevent_view.py new file mode 100644 index 0000000000000000000000000000000000000000..f0952560f802ddc5c897c8234a9b510da5eb127e --- /dev/null +++ b/aleksis/core/migrations/0072_birthdayevent_view.py @@ -0,0 +1,26 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0071_constrain_calendar_event_starting_before_ending'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunSQL( + """ + -- Create view for BirthdayEvents + CREATE VIEW core_birthdayevent AS + SELECT id, + id AS person_id, + date_of_birth AS dt_start, + 'YEARLY' AS rrule + FROM core_person + WHERE date_of_birth IS NOT NULL; + """ + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index fcba78889865e00191e81df9cbe771ec54be8e29..9d09ac41592207d56d9bbfd5bea10e0f6902b4d7 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -1,19 +1,23 @@ # flake8: noqa: DJ12 import os -from collections.abc import Iterable +import warnings +from collections.abc import Iterable, Sequence from datetime import datetime +from hashlib import sha256 from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Union +from xml.etree import ElementTree from django.conf import settings from django.contrib import messages from django.contrib.auth.views import LoginView, RedirectURLMixin from django.db import models -from django.db.models import JSONField, QuerySet +from django.db.models import JSONField, Q, 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 HttpRequest, HttpResponse +from django.urls import reverse from django.utils import timezone from django.utils.functional import classproperty, lazy from django.utils.translation import gettext as _ @@ -52,10 +56,10 @@ class _ExtensibleModelBase(models.base.ModelBase): - Register all AlekSIS models with django-reversion """ - def __new__(mcls, name, bases, attrs): - mcls = super().__new__(mcls, name, bases, attrs) + def __new__(mcls, name, bases, attrs, **kwargs): + mcls = super().__new__(mcls, name, bases, attrs, **kwargs) - if "Meta" not in attrs or not attrs["Meta"].abstract: + if not mcls._meta.abstract: # Register all non-abstract models with django-reversion mcls = reversion.register(mcls) @@ -491,10 +495,21 @@ class RegistryObject: _registry: ClassVar[Optional[dict[str, type["RegistryObject"]]]] = None name: ClassVar[str] = "" - def __init_subclass__(cls): - if getattr(cls, "_registry", None) is None: + def __init_subclass__(cls, is_registry=False, register=True): + parent_registry = getattr(cls, "_registry", None) + cls._is_registry = False + + if parent_registry is None or is_registry: cls._registry = {} - else: + cls._is_registry = True + + if getattr(cls, "_parent_registries", None) is None: + cls._parent_registries = [] + + if parent_registry is not None: + cls._parent_registries.append(parent_registry) + + elif register: if not cls.name: cls.name = cls.__name__ cls._register() @@ -504,28 +519,158 @@ class RegistryObject: if cls.name and cls.name not in cls._registry: cls._registry[cls.name] = cls + for registry in cls._parent_registries: + registry[cls.name] = cls + + @classmethod + def get_registry_objects(cls) -> dict[str, type["RegistryObject"]]: + return cls._registry + + @classmethod + def get_registry_objects_recursive(cls) -> dict[str, type["RegistryObject"]]: + objs = cls._registry.copy() + for sub_registry in cls.get_sub_registries().values(): + objs |= sub_registry.get_registry_objects_recursive() + return objs + + @classmethod + def get_sub_registries(cls) -> dict[str, type["RegistryObject"]]: + registries = {} + for registry in cls.__subclasses__(): + if registry._registry: + registries[registry.name] = registry + return registries + + @classmethod + def get_sub_registries_recursive(cls) -> dict[str, type["RegistryObject"]]: + registries = {} + for sub_registry in cls.get_sub_registries().values(): + registries |= sub_registry.get_sub_registries() + return registries + @classproperty def registered_objects_dict(cls) -> dict[str, type["RegistryObject"]]: """Get dict of registered objects.""" - return cls._registry + return cls.get_registry_objects() @classproperty def registered_objects_list(cls) -> list[type["RegistryObject"]]: """Get list of registered objects.""" - return list(cls._registry.values()) + return list(cls.get_registry_objects().values()) @classmethod def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]: """Get registered object by name.""" return cls.registered_objects_dict.get(name) + @classmethod + def get_sub_registry_by_name(cls, name: str) -> Optional[type["RegistryObject"]]: + return cls.get_sub_registries().get(name) + class ObjectAuthenticator(RegistryObject): def authenticate(self, request, obj): raise NotImplementedError() -class CalendarEventMixin(RegistryObject): +class DAVResource(RegistryObject): + """Mixin for objects to provide via DAV.""" + + dav_verbose_name: ClassVar[str] = "" + dav_content_type: ClassVar[str] = "" + + dav_ns: ClassVar[dict[str, str]] = {} + dav_resource_types: ClassVar[list[str]] = [] + + # Hint: We do not support dead properties for now + dav_live_props: ClassVar[list[tuple[str, str]]] = [ + ("DAV:", "displayname"), + ("DAV:", "resourcetype"), + ("DAV:", "getcontenttype"), + ("DAV:", "getcontentlength"), + ] + dav_live_prop_methods: ClassVar[dict[tuple[str, str], str]] = {} + + @classmethod + def get_dav_verbose_name(cls, request: Optional[HttpRequest] = None) -> str: + """Return the verbose name of the calendar feed.""" + return str(cls.dav_verbose_name) + + @classmethod + def _register_dav_ns(cls): + for prefix, url in cls.dav_ns.items(): + ElementTree.register_namespace(prefix, url) + ElementTree.register_namespace("d", "DAV:") + + @classmethod + def _add_dav_propnames(cls, prop: ElementTree.SubElement) -> None: + for ns, propname in cls.dav_live_props: + ElementTree.SubElement(prop, f"{{{ns}}}{propname}") + + @classmethod + def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str: + raise NotImplementedError + + @classmethod + def get_dav_file_content( + cls, + request: HttpRequest, + objects: Optional[Iterable | QuerySet] = None, + params: Optional[dict[str, any]] = None, + ) -> str: + raise NotImplementedError + + @classmethod + def get_dav_content_type(cls) -> str: + return cls.dav_content_type + + @classmethod + def getetag(cls, request: HttpRequest, objects) -> str: + warnings.warn( + f"""The class {cls.__name__} does not override the getetag method and uses the slow and potentially unstable default implementation.""" # noqa: E501 + ) + try: + content = cls.get_dav_file_content(request, objects) + except NotImplementedError: + content = b"" + etag = sha256() + etag.update(content) + digest = etag.hexdigest() + return digest + + +class ContactMixin(DAVResource, RegistryObject, is_registry=True): + name: ClassVar[str] = "contact" # Unique name for the calendar feed + verbose_name: ClassVar[str] = "" # Shown name of the feed + link: ClassVar[str] = "" # Link for the feed, optional + description: ClassVar[str] = "" # Description of the feed, optional + color: ClassVar[str] = "#222222" # Color of the feed, optional + permission_required: ClassVar[str] = "" + + dav_ns = { + "carddav": "urn:ietf:params:xml:ns:carddav", + } + dav_resource_types = ["{urn:ietf:params:xml:ns:carddav}addressbook"] + dav_content_type = "text/vcard" + + dav_live_props: ClassVar[list[tuple[str, str]]] = [ + ("DAV:", "displayname"), + ("DAV:", "resourcetype"), + ("DAV:", "getcontenttype"), + ("DAV:", "getcontentlength"), + (dav_ns["carddav"], "addressbook-description"), + ] + + @classmethod + def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str: + return reverse("dav_resource_contact", args=["contact", cls.name, reference_object.id]) + + @classmethod + def value_unique_id(cls, reference_object, request: HttpRequest) -> str: + return f"{cls.name}-{reference_object.id}" + + +class CalendarEventMixin(DAVResource, RegistryObject, is_registry=True): """Mixin for calendar feeds. This mixin can be used to create calendar feeds for objects. It can be used @@ -562,12 +707,28 @@ class CalendarEventMixin(RegistryObject): 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 - permission_required: str = "" + name: ClassVar[str] = "calendar" # Unique name for the calendar feed + verbose_name: ClassVar[str] = "" # Shown name of the feed + link: ClassVar[str] = "" # Link for the feed, optional + description: ClassVar[str] = "" # Description of the feed, optional + color: ClassVar[str] = "#222222" # Color of the feed, optional + permission_required: ClassVar[str] = "" + + dav_ns = { + "cal": "urn:ietf:params:xml:ns:caldav", + "ical": "http://apple.com/ns/ical/", + } + dav_resource_types = ["{urn:ietf:params:xml:ns:caldav}calendar"] + dav_content_type = "text/calendar; charset=utf8; component=vevent" + + dav_live_props: ClassVar[list[tuple[str, str]]] = [ + ("DAV:", "displayname"), + ("DAV:", "resourcetype"), + ("DAV:", "getcontenttype"), + ("DAV:", "getcontentlength"), + (dav_ns["ical"], "calendar-color"), + (dav_ns["cal"], "calendar-description"), + ] @classmethod def get_verbose_name(cls, request: Optional[HttpRequest] = None) -> str: @@ -617,6 +778,7 @@ class CalendarEventMixin(RegistryObject): """Create an event for the given reference object and add it to the feed.""" values = {} values["timestamp"] = timezone.now() + values["description"] = None for field in cls.get_event_field_names(): field_value = cls.get_event_field_value( reference_object, field, request=request, params=params @@ -643,13 +805,42 @@ class CalendarEventMixin(RegistryObject): @classmethod def get_objects( cls, - request: Optional[HttpRequest] = None, - params: Optional[dict[str, any]] = None, + request: HttpRequest | None = None, + params: dict[str, any] | None = None, start: Optional[datetime] = None, end: Optional[datetime] = None, - ) -> Iterable: - """Return the objects to create the calendar feed for.""" - raise NotImplementedError + start_qs: QuerySet | None = None, + additional_filter: Q | None = None, + select_related: Sequence | None = None, + prefetch_related: Sequence | None = None, + expand_start: Optional[datetime] = None, + expand_end: Optional[datetime] = None, + expand: bool = False, + ) -> QuerySet: + """Return all objects that should be included in the calendar.""" + qs = cls.objects if start_qs is None else start_qs + if isinstance(qs, PolymorphicBaseManager): + qs = qs.instance_of(cls) + if not start or not end: + if additional_filter is not None: + qs = qs.filter(additional_filter) + if select_related is not None: + qs = qs.select_related(*select_related) + if prefetch_related is not None: + qs = qs.prefetch_related(*prefetch_related) + else: + qs = cls.objects.with_occurrences( + start, + end, + start_qs=qs, + additional_filter=additional_filter, + select_related=select_related, + prefetch_related=prefetch_related, + expand_start=expand_start, + expand_end=expand_end, + expand=expand, + ) + return cls.objects.filter(id__in=qs.values_list("id", flat=True)) @classmethod def create_feed( @@ -658,7 +849,7 @@ class CalendarEventMixin(RegistryObject): params: Optional[dict[str, any]] = None, start: Optional[datetime] = None, end: Optional[datetime] = None, - queryset: Optional[QuerySet] = None, + queryset: Optional[Iterable | QuerySet] = None, **kwargs, ) -> ExtendedICal20Feed: """Create the calendar feed with all events.""" @@ -685,7 +876,7 @@ class CalendarEventMixin(RegistryObject): ) -> Calendar: """Return the calendar object.""" feed = cls.create_feed(request=request, params=params, queryset=queryset) - return feed.get_calendar_object() + return feed.get_calendar_object(params=params) @classmethod def get_events( @@ -703,7 +894,7 @@ class CalendarEventMixin(RegistryObject): feed = cls.create_feed( request=request, params=params, start=start, end=end, queryset=queryset, **kwargs ) - return feed.get_calendar_object(with_reference_object=with_reference_object) + return feed.get_calendar_object(with_reference_object=with_reference_object, params=params) @classmethod def get_single_events( @@ -777,3 +968,23 @@ class CalendarEventMixin(RegistryObject): @classmethod def get_enabled_feeds(cls, request: HttpRequest | None = None): return [feed for feed in cls.valid_feeds if feed.get_enabled(request)] + + @classmethod + def get_dav_verbose_name(cls, request: Optional[HttpRequest] = None) -> str: + return str(cls.get_verbose_name()) + + @classmethod + def get_dav_file_content( + cls, + request: HttpRequest, + objects: Optional[Iterable | QuerySet] = None, + params: Optional[dict[str, any]] = None, + expand_start: datetime | None = None, + expand_end: datetime | None = None, + ): + feed = cls.create_feed(request, queryset=objects, params=params) + return feed.to_ical(params=params) + + @classmethod + def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str: + return reverse("dav_resource_calendar", args=["calendar", cls.name, reference_object.id]) diff --git a/aleksis/core/models.py b/aleksis/core/models.py index efa77d6946ef2582bf08a5ab3af2bf2d35c6efaa..216207e2d6253354e5cdef6e42594e317cd6f163 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -15,7 +15,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator from django.db import models -from django.db.models import Q, QuerySet +from django.db.models import F, Q, QuerySet from django.dispatch import receiver from django.forms.widgets import Media from django.http import HttpRequest @@ -62,18 +62,20 @@ from aleksis.core.data_checks import ( from .managers import ( AlekSISBaseManagerWithoutMigrations, + CalendarEventManager, + CalendarEventMixinManager, GroupManager, GroupQuerySet, HolidayManager, InstalledWidgetsDashboardWidgetOrderManager, PersonManager, PersonQuerySet, - RecurrencePolymorphicManager, SchoolTermQuerySet, UninstallRenitentPolymorphicManager, ) from .mixins import ( CalendarEventMixin, + ContactMixin, ExtensibleModel, ExtensiblePolymorphicModel, GlobalPermissionModel, @@ -172,13 +174,15 @@ class SchoolTerm(ExtensibleModel): ordering = ["-date_start"] -class Person(ExtensibleModel): +class Person(ContactMixin, ExtensibleModel): """Person model. A model describing any person related to a school, including, but not limited to, students, teachers and guardians (parents). """ + name = "person" + objects = PersonManager.from_queryset(PersonQuerySet)() class Meta: @@ -207,6 +211,8 @@ class Person(ExtensibleModel): M = "M", _("male") X = "X", _("other") + SEX_CHOICES_VCARD = {"F": "F", "M": "M", "X": "O"} + user = models.OneToOneField( get_user_model(), on_delete=models.SET_NULL, @@ -389,6 +395,114 @@ class Person(ExtensibleModel): vcal.params["ROLE"] = vText(role) return vcal + def _is_unrequested_prop(self, efield, params): + """Return True if specific fields are requested and efield is not one of those. + If no fields are specified or efield is requested, return False""" + + comp_name = "VCARD" + + return ( + params is not None + and comp_name in params + and efield is not None + and efield.upper() not in params[comp_name] + ) + + def as_vcard(self, request, params) -> str: + """Get this person as vCard. + + Uses old version of vCard (3.0) thanks to chaotic breakage in Evolution. + """ + + card = [ + "BEGIN:VCARD", + "VERSION:3.0", + "KIND:individual", + "PRODID:-//AlekSIS//AlekSIS//EN", + ] + + if not self._is_unrequested_prop("UID", params): + # FIXME replace with UUID once implemented + card.append(f"UID:{request.build_absolute_uri(self.get_absolute_url())}") + + # Name + if not self._is_unrequested_prop("FN", params): + card.append(f"FN:{self.addressing_name}") + + if not self._is_unrequested_prop("N", params): + card.append(f"N:{self.last_name};{self.first_name};{self.additional_name};;") + + # Birthday + if ( + not self._is_unrequested_prop("BDAY", params) + and self.date_of_birth + and request.user.has_perm("core.view_personal_details", self) + ): + card.append(f"BDAY:{self.date_of_birth.isoformat()}") + + # Email + if ( + not self._is_unrequested_prop("EMAIL", params) + and self.email + and request.user.has_perm("core.view_contact_details", self) + ): + card.append(f"EMAIL:{self.email}") + + # Phone Numbers + if not self._is_unrequested_prop("TEL", params): + if self.phone_number and request.user.has_perm("core.view_contact_details", self): + card.append(f"TEL;TYPE=home:{self.phone_number}") + + if self.mobile_number and request.user.has_perm("core.view_contact_details", self): + card.append(f"TEL;TYPE=cell:{self.mobile_number}") + + # Address + if ( + not self._is_unrequested_prop("ADR", params) + and self.street + and self.postal_code + and self.place + and request.user.has_perm("core.view_address_rule", self) + ): + card.append( + f"ADR;TYPE=home:;;{self.street} {self.housenumber};" + f"{self.place};;{self.postal_code};" + ) + + card.append("END:VCARD") + + return "\r\n".join(card) + "\r\n" + + @classmethod + def get_objects( + cls, + request: HttpRequest | None = None, + start_qs: QuerySet | None = None, + additional_filter: Q | None = None, + ) -> QuerySet: + """Return all objects that should be included in the contact list.""" + qs = cls.objects.all() if start_qs is None else start_qs + if request: + 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.filter(additional_filter) if additional_filter else qs + + @classmethod + def get_dav_file_content( + cls, + request: HttpRequest, + objects: Optional[Iterable | QuerySet] = None, + params: Optional[dict[str, any]] = None, + ) -> str: + if objects is None: + objects = cls.get_objects(request) + content = "" + for person in objects: + content += person.as_vcard(request, params) + return content.encode() + def save(self, *args, **kwargs): # Determine all fields that were changed since last load changed = self.user_info_tracker.changed() @@ -1501,7 +1615,9 @@ class Room(ExtensibleModel): ] -class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceModel): +class CalendarEvent( + CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceModel, register=False +): """A planned event in a calendar. To make use of this model, you need to inherit from this model. @@ -1517,7 +1633,7 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo Please refer to the documentation of `CalendarEventMixin` for more information. """ - objects = RecurrencePolymorphicManager() + objects = CalendarEventManager() datetime_start = models.DateTimeField( verbose_name=_("Start date and time"), null=True, blank=True @@ -1566,11 +1682,8 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo """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 + # RFC 5545 states that the end date is not inclusive + return reference_object.date_end + timedelta(days=1) @classmethod def value_rrule( @@ -1650,34 +1763,6 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo calendar_alarm.get_alarm(request) for calendar_alarm in reference_object.alarms.all() ] - @classmethod - def get_objects( - cls, - request: HttpRequest | None = None, - params: dict[str, any] | None = None, - start: Optional[datetime] = None, - end: Optional[datetime] = None, - start_qs: QuerySet | None = None, - additional_filter: Q | None = None, - select_related: Sequence | None = None, - prefetch_related: Sequence | None = None, - ) -> QuerySet: - """Return all objects that should be included in the calendar.""" - start_qs = cls.objects if start_qs is None else start_qs - start_qs = start_qs.instance_of(cls) - if not start or not end: - start = timezone.now() - timedelta(days=50) - end = timezone.now() + timedelta(days=50) - qs = cls.objects.with_occurrences( - start, - end, - start_qs=start_qs, - additional_filter=additional_filter, - select_related=select_related, - prefetch_related=prefetch_related, - ) - return qs - def save(self, *args, **kwargs): if ( self.datetime_start @@ -1714,6 +1799,18 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo check=~Q(datetime_end__isnull=True, date_end__isnull=True), name="datetime_end_or_date_end", ), + models.CheckConstraint( + check=Q(datetime_end__gte=F("datetime_start")) + | Q(datetime_start__isnull=True) + | Q(datetime_end__isnull=True), + name="datetime_start_before_end", + ), + models.CheckConstraint( + check=Q(date_end__gte=F("date_start")) + | Q(date_start__isnull=True) + | Q(date_end__isnull=True), + name="date_start_before_end", + ), models.CheckConstraint( check=~(Q(datetime_start__isnull=False, timezone="") & ~Q(recurrences="")), name="timezone_if_datetime_start_and_recurring", @@ -1737,44 +1834,76 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo class FreeBusy(CalendarEvent): - pass + name = "" + + @classmethod + def value_title(cls, reference_object: "FreeBusy", request: HttpRequest | None = None) -> str: + return "" -class BirthdayEvent(CalendarEventMixin): +class BirthdayEvent(CalendarEventMixin, models.Model): """A calendar feed with all birthdays.""" name = "birthdays" verbose_name = _("Birthdays") permission_required = "core.view_birthday_calendar" + person = models.ForeignKey(Person, on_delete=models.DO_NOTHING) + + objects = CalendarEventMixinManager() + + class Meta: + managed = False + db_table = "core_birthdayevent" + + def __str__(self): + return self.value_title(self) + @classmethod - def value_title(cls, reference_object: Person, request: HttpRequest | None = None) -> str: - return _("{}'s birthday").format(reference_object.addressing_name) + def value_title( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> str: + return _("{}'s birthday").format(reference_object.person.addressing_name) @classmethod - def value_description(cls, reference_object: Person, request: HttpRequest | None = None) -> str: - return f"{reference_object.addressing_name} \ - was born on {date_format(reference_object.date_of_birth)}." + def value_description( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> str: + description = f"{reference_object.person.addressing_name} " + description += f"was born on {date_format(reference_object.person.date_of_birth)}." + return description @classmethod def value_start_datetime( - cls, reference_object: Person, request: HttpRequest | None = None + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> date: + return reference_object.person.date_of_birth + + @classmethod + def value_end_datetime( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None ) -> date: - return reference_object.date_of_birth + return None @classmethod - def value_rrule(cls, reference_object: Person, request: HttpRequest | None = None) -> vRecur: + def value_rrule( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> vRecur: return build_rrule_from_text("FREQ=YEARLY") @classmethod - def value_unique_id(cls, reference_object: Person, request: HttpRequest | None = None) -> str: - return f"birthday-{reference_object.id}" + def value_unique_id( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> str: + return f"birthday-{reference_object.person.id}" @classmethod - def value_meta(cls, reference_object: Person, request: HttpRequest | None = None) -> dict: + def value_meta( + cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None + ) -> dict: return { - "name": reference_object.addressing_name, - "date_of_birth": reference_object.date_of_birth.isoformat(), + "name": reference_object.person.addressing_name, + "date_of_birth": reference_object.person.date_of_birth.isoformat(), } @classmethod @@ -1788,6 +1917,8 @@ class BirthdayEvent(CalendarEventMixin): params: dict[str, any] | None = None, start: Optional[datetime] = None, end: Optional[datetime] = None, + start_qs: QuerySet | None = None, + additional_filter: Q | None = None, ) -> QuerySet: qs = Person.objects.filter(date_of_birth__isnull=False) if request: @@ -1795,7 +1926,14 @@ class BirthdayEvent(CalendarEventMixin): Q(pk=request.user.person.pk) | Q(pk__in=get_objects_for_user(request.user, "core.view_personal_details", qs)) ) - return qs + + q = Q(person__in=qs) + if additional_filter is not None: + q = q & additional_filter + + return super().get_objects( + request=request, params=params, start_qs=start_qs, additional_filter=q + ) class Holiday(CalendarEvent): @@ -1892,6 +2030,10 @@ class PersonalEvent(CalendarEvent): persons = models.ManyToManyField(Person, related_name="+", blank=True) groups = models.ManyToManyField(Group, related_name="+", blank=True) + @classmethod + def get_description(cls, request: HttpRequest | None = None) -> str: + return "" + @classmethod def get_color(cls, request: HttpRequest | None = None) -> str: return get_site_preferences()["calendar__personal_event_color"] @@ -1955,18 +2097,25 @@ class PersonalEvent(CalendarEvent): params: dict[str, any] | None = None, start: Optional[datetime] = None, end: Optional[datetime] = None, + start_qs: QuerySet | None = None, additional_filter: Q | None = None, **kwargs, ) -> QuerySet: - q = additional_filter + q = additional_filter if additional_filter is not None else Q() if request: - q = ( + q = q & ( Q(owner=request.user.person) | Q(persons=request.user.person) | Q(groups__members=request.user.person) ) qs = super().get_objects( - request=request, params=params, start=start, end=end, additional_filter=q, **kwargs + request=request, + params=params, + start=start, + end=end, + start_qs=start_qs, + additional_filter=q, + **kwargs, ) return qs diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index 06dc986c4d8f24722387dcc121fd5239c570754e..86cf261e3aaf37c84e9f6c0b09348d0116d425f7 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -182,15 +182,26 @@ view_announcements_predicate = has_person & ( ) rules.add_perm("core.view_announcements_rule", view_announcements_predicate) -# Create or edit announcement -create_or_edit_announcement_predicate = has_person & ( +# View announcement +view_announcement_predicate = has_person & ( + has_global_perm("core.view_announcement") | has_object_perm("core.view_announcement") +) +rules.add_perm("core.view_announcement_rule", view_announcements_predicate) + +# Create announcement +create_announcement_predicate = view_announcements_predicate & ( has_global_perm("core.add_announcement") - & (has_global_perm("core.change_announcement") | has_object_perm("core.change_announcement")) ) -rules.add_perm("core.create_or_edit_announcement_rule", create_or_edit_announcement_predicate) +rules.add_perm("core.create_announcement_rule", create_announcement_predicate) + +# Edit announcement +edit_announcement_predicate = view_announcement_predicate & ( + has_global_perm("core.change_announcement") | has_object_perm("core.change_announcement") +) +rules.add_perm("core.edit_announcement_rule", edit_announcement_predicate) # Delete announcement -delete_announcement_predicate = has_person & ( +delete_announcement_predicate = view_announcement_predicate & ( has_global_perm("core.delete_announcement") | has_object_perm("core.delete_announcement") ) rules.add_perm("core.delete_announcement_rule", delete_announcement_predicate) @@ -380,20 +391,19 @@ invite_predicate = has_person & invite_enabled_predicate & has_global_perm("core rules.add_perm("core.invite_rule", invite_predicate) # OAuth2 permissions -create_oauthapplication_predicate = has_person & has_global_perm("core.add_oauthapplication") -rules.add_perm("core.create_oauthapplication_rule", create_oauthapplication_predicate) +view_oauthapplication_predicate = has_person & has_global_perm("core.view_oauthapplication") +rules.add_perm("core.view_oauthapplication_rule", view_oauthapplication_predicate) -view_oauth_applications_predicate = has_person & has_global_perm("core.view_oauthapplication") -rules.add_perm("core.view_oauthapplications_rule", view_oauth_applications_predicate) +rules.add_perm("core.view_oauthapplications_rule", view_oauthapplication_predicate) -view_oauth_application_predicate = has_person & has_global_perm("core.view_oauthapplication") -rules.add_perm("core.view_oauthapplication_rule", view_oauth_application_predicate) +create_oauthapplication_predicate = has_person & has_global_perm("core.add_oauthapplication") +rules.add_perm("core.create_oauthapplication_rule", create_oauthapplication_predicate) -edit_oauth_application_predicate = has_person & has_global_perm("core.change_oauthapplication") -rules.add_perm("core.edit_oauthapplication_rule", edit_oauth_application_predicate) +delete_oauthapplication_predicate = has_person & has_global_perm("core.delete_oauthapplication") +rules.add_perm("core.delete_oauthapplication_rule", delete_oauthapplication_predicate) -delete_oauth_applications_predicate = has_person & has_global_perm("core.delete_oauth_applications") -rules.add_perm("core.delete_oauth_applications_rule", delete_oauth_applications_predicate) +edit_oauthapplication_predicate = has_person & has_global_perm("core.change_oauthapplication") +rules.add_perm("core.edit_oauthapplication_rule", edit_oauthapplication_predicate) view_django_admin_predicate = has_person & is_superuser rules.add_perm("core.view_django_admin_rule", view_django_admin_predicate) @@ -404,7 +414,7 @@ view_admin_menu_predicate = has_person & ( | impersonate_predicate | view_system_status_predicate | view_data_check_results_predicate - | view_oauth_applications_predicate + | view_oauthapplication_predicate | view_dashboard_widget_predicate | view_django_admin_predicate ) diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index 19746f570b739f0c9b4e6f9587e8d0419be6ebbb..9fa3fe2029a2e7f4c82fd3f65c856ff7ca24fd57 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -6,12 +6,15 @@ from django.db.models import Q import graphene import graphene_django_optimizer +from graphene.types.resolver import dict_or_attr_resolver, set_default_resolver from guardian.shortcuts import get_objects_for_user from haystack.inputs import AutoQuery from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex +from oauth2_provider.generators import generate_client_id, generate_client_secret from ..models import ( + Announcement, CustomMenu, DynamicRoute, Group, @@ -19,6 +22,7 @@ from ..models import ( Holiday, Notification, OAuthAccessToken, + OAuthApplication, PDFFile, Person, Room, @@ -26,6 +30,7 @@ from ..models import ( TaskUserAssignment, ) from ..util.apps import AppConfig +from ..util.auth_helpers import AppScopes from ..util.core_helpers import ( filter_active_school_term, get_active_school_term, @@ -34,6 +39,12 @@ from ..util.core_helpers import ( get_app_packages, has_person, ) +from .announcement import ( + AnnouncementBatchCreateMutation, + AnnouncementBatchDeleteMutation, + AnnouncementBatchPatchMutation, + AnnouncementType, +) from .base import FilterOrderList from .calendar import CalendarBaseType, SetCalendarStatusMutation from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType @@ -56,7 +67,16 @@ from .holiday import ( from .installed_apps import AppType from .message import MessageType from .notification import MarkNotificationReadMutation, NotificationType -from .oauth import OAuthAccessTokenType, OAuthBatchRevokeTokenMutation +from .oauth import ( + InitOAuthApplicationType, + OAuthAccessTokenType, + OAuthApplicationBatchCreateMutation, + OAuthApplicationBatchDeleteMutation, + OAuthApplicationBatchPatchMutation, + OAuthApplicationType, + OAuthBatchRevokeTokenMutation, + OAuthScopeType, +) from .pdf import PDFFileType from .permissions import ObjectPermissionInputType, ObjectPermissionResultType from .person import ( @@ -89,6 +109,17 @@ from .two_factor import TwoFactorType from .user import UserType +def custom_default_resolver(attname, default_value, root, info, **args): + """Custom default resolver to ensure resolvers are set for all queries.""" + if info.parent_type.name == "GlobalQuery": + raise NotImplementedError(f"No own resolver defined for {attname}") + + return dict_or_attr_resolver(attname, default_value, root, info, **args) + + +set_default_resolver(custom_default_resolver) + + class Query(graphene.ObjectType): ping = graphene.String(payload=graphene.String()) @@ -138,6 +169,12 @@ class Query(graphene.ObjectType): group_types = FilterOrderList(GroupTypeType) + announcements = FilterOrderList(AnnouncementType) + + oauth_applications = FilterOrderList(OAuthApplicationType) + init_oauth_application = graphene.Field(InitOAuthApplicationType) + oauth_scopes = graphene.List(OAuthScopeType) + object_permissions = graphene.List( ObjectPermissionResultType, input=graphene.List(ObjectPermissionInputType) ) @@ -146,6 +183,8 @@ class Query(graphene.ObjectType): return payload def resolve_notifications(root, info, **kwargs): + if info.context.user.is_anonymous: + return [] qs = Notification.objects.filter( Q( pk__in=get_objects_for_user( @@ -377,6 +416,40 @@ class Query(graphene.ObjectType): return results + @staticmethod + def resolve_announcements(root, info, **kwargs): + qs = get_objects_for_user( + info.context.user, "core.view_announcement", Announcement.objects.all() + ) + return graphene_django_optimizer.query(qs, info) + + @staticmethod + def resolve_oauth_applications(root, info, **kwargs): + if not info.context.user.has_perm("core.view_oauthapplication_rule"): + return [] + return graphene_django_optimizer.query(OAuthApplication.objects.all(), info) + + @staticmethod + def resolve_init_oauth_application(root, info, **kwargs): + if not info.context.user.has_perm("core.create_oauthapplication_rule"): + return None + return InitOAuthApplicationType( + client_id=generate_client_id(), client_secret=generate_client_secret() + ) + + @staticmethod + def resolve_oauth_scopes(root, info, **kwargs): + if not ( + info.context.user.has_perm("core.view_oauthapplication_rule") + | info.context.user.has_perm("core.create_oauthapplication_rule") + ): + return [] + + return [ + OAuthScopeType(name=key, description=value) + for key, value in AppScopes().get_all_scopes().items() + ] + class Mutation(graphene.ObjectType): delete_persons = PersonBatchDeleteMutation.Field() @@ -414,6 +487,14 @@ class Mutation(graphene.ObjectType): delete_groups = GroupBatchDeleteMutation.Field() + create_announcements = AnnouncementBatchCreateMutation.Field() + delete_announcements = AnnouncementBatchDeleteMutation.Field() + patch_announcements = AnnouncementBatchPatchMutation.Field() + + create_oauth_applications = OAuthApplicationBatchCreateMutation.Field() + delete_oauth_applications = OAuthApplicationBatchDeleteMutation.Field() + patch_oauth_applications = OAuthApplicationBatchPatchMutation.Field() + def build_global_schema(): """Build global GraphQL schema from all apps.""" diff --git a/aleksis/core/schema/announcement.py b/aleksis/core/schema/announcement.py new file mode 100644 index 0000000000000000000000000000000000000000..06820afcdc52d0475fff50fd7670fe64665338ca --- /dev/null +++ b/aleksis/core/schema/announcement.py @@ -0,0 +1,117 @@ +from django.contrib.contenttypes.models import ContentType + +import graphene +from graphene_django import DjangoObjectType + +from ..models import ( + Announcement, + Group, + Person, +) +from .base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, + PermissionsTypeMixin, +) +from .group import GroupType +from .person import PersonType + + +class AnnouncementType(PermissionsTypeMixin, DjangoObjectType): + class Meta: + model = Announcement + fields = ( + "id", + "valid_from", + "valid_until", + "title", + "description", + "priority", + ) + + recipient_groups = graphene.List(GroupType) + recipient_persons = graphene.List(PersonType) + + def resolve_recipient_groups(root, info, **kwargs): + return root.get_recipients_for_model(Group) + + def resolve_recipient_persons(root, info, **kwargs): + return root.get_recipients_for_model(Person) + + +class AnnouncementBatchCreateMutation(BaseBatchCreateMutation): + class Meta: + model = Announcement + permissions = ("core.create_announcement_rule",) + only_fields = ( + "valid_from", + "valid_until", + "title", + "description", + "priority", + ) + custom_fields = { + "recipient_groups": graphene.List(graphene.ID), + "recipient_persons": graphene.List(graphene.ID), + } + + @classmethod + def after_mutate(cls, root, info, input, created_objs, return_data): # noqa + """Create AnnouncementRecipients""" + for spec, obj in zip(input, created_objs): + for group in spec.recipient_groups: + obj.recipients.create(recipient=Group.objects.get(id=group)) + for person in spec.recipient_persons: + obj.recipients.create(recipient=Person.objects.get(id=person)) + + +class AnnouncementBatchDeleteMutation(BaseBatchDeleteMutation): + class Meta: + model = Announcement + permissions = ("core.delete_announcement_rule",) + + @classmethod + def before_save(cls, root, info, ids, qs_to_delete): + """Delete AnnouncementRecipients""" + for announcement in qs_to_delete: + # Copied from forms.py + announcement.recipients.all().delete() + + +class AnnouncementBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = Announcement + permissions = ("core.edit_announcement_rule",) + only_fields = ( + "id", + "valid_from", + "valid_until", + "title", + "description", + "priority", + ) + custom_fields = { + "recipient_groups": graphene.List(graphene.ID), + "recipient_persons": graphene.List(graphene.ID), + } + + @classmethod + def after_mutate(cls, root, info, input, updated_objs, return_data): # noqa + """Update AnnouncementRecipients""" + + def delete_announcement_recipients_of_model(announcement, model): + # Del & then recreate + ct = ContentType.objects.get_for_model(model) + announcement.recipients.filter(content_type=ct).delete() + + for spec, obj in zip(input, updated_objs): + if spec.recipient_groups: + delete_announcement_recipients_of_model(obj, Group) + for group in spec.recipient_groups: + obj.recipients.create(recipient=Group.objects.get(id=group)) + + if spec.recipient_persons: + delete_announcement_recipients_of_model(obj, Person) + for person in spec.recipient_persons: + obj.recipients.create(recipient=Person.objects.get(id=person)) diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py index 955a202758c1c17a71f034df55e0100f43e41a8a..5358b2ebad954494bf9e2e4e2d30c802fd9e2b0f 100644 --- a/aleksis/core/schema/base.py +++ b/aleksis/core/schema/base.py @@ -115,7 +115,8 @@ class PermissionBatchCreateMixin: @classmethod def after_create_obj(cls, root, info, data, obj, input): # noqa - if isinstance(cls._meta.permissions, Iterable) and not info.context.user.has_perms( + super().after_create_obj(root, info, data, obj, input) + if not isinstance(cls._meta.permissions, Iterable) or not info.context.user.has_perms( cls._meta.permissions, obj ): raise PermissionDenied() @@ -133,7 +134,8 @@ class PermissionBatchPatchMixin: @classmethod def after_update_obj(cls, root, info, input, obj, full_input): # noqa - if isinstance(cls._meta.permissions, Iterable) and not info.context.user.has_perms( + super().after_update_obj(root, info, input, obj, full_input) + if not isinstance(cls._meta.permissions, Iterable) or not info.context.user.has_perms( cls._meta.permissions, obj ): raise PermissionDenied() @@ -151,10 +153,12 @@ class PermissionBatchDeleteMixin: @classmethod def before_save(cls, root, info, ids, qs_to_delete): # noqa - if isinstance(cls._meta.permissions, Iterable): - for obj in qs_to_delete: - if not info.context.user.has_perms(cls._meta.permissions, obj): - raise PermissionDenied() + super().before_save(root, info, ids, qs_to_delete) + if not isinstance(cls._meta.permissions, Iterable): + raise PermissionDenied() + for obj in qs_to_delete: + if not info.context.user.has_perms(cls._meta.permissions, obj): + raise PermissionDenied() class PermissionPatchMixin: @@ -268,10 +272,12 @@ class ModelValidationMixin: @classmethod def after_update_obj(cls, root, info, data, obj, full_input): + super().after_update_obj(root, info, data, obj, full_input) obj.full_clean() @classmethod def before_create_obj(cls, info, data, obj): + super().before_create_obj(info, data, obj) obj.full_clean() diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py index d79c7c0f99274823f9b96f6a1239134e98069be1..80330b385db1a067714bd99e70d7eca297796b22 100644 --- a/aleksis/core/schema/calendar.py +++ b/aleksis/core/schema/calendar.py @@ -27,7 +27,7 @@ class CalendarEventType(ObjectType): return root["SUMMARY"] def resolve_description(root, info, **kwargs): - return root["DESCRIPTION"] + return root.get("DESCRIPTION", "") def resolve_location(root, info, **kwargs): return root.get("LOCATION", "") @@ -39,7 +39,7 @@ class CalendarEventType(ObjectType): return root["DTEND"].dt def resolve_color(root, info, **kwargs): - return root["COLOR"] + return root.get("COLOR") def resolve_uid(root, info, **kwargs): return root["UID"] @@ -88,7 +88,9 @@ class CalendarType(ObjectType): return root.get_description(info.context) def resolve_url(root, info, **kwargs): - return info.context.build_absolute_uri(reverse("calendar_feed", args=[root.name])) + return info.context.build_absolute_uri( + reverse("calendar_feed", args=["calendar", root.name]) + ) def resolve_color(root, info, **kwargs): return root.get_color(info.context) @@ -129,4 +131,4 @@ class CalendarBaseType(ObjectType): return CalendarEventMixin.get_enabled_feeds(info.context) def resolve_all_feeds_url(root, info, **kwargs): - return info.context.build_absolute_uri(reverse("all_calendar_feeds")) + return info.context.build_absolute_uri(reverse("all_calendar_feeds", args=["calendar"])) diff --git a/aleksis/core/schema/oauth.py b/aleksis/core/schema/oauth.py index a70afdf7723fd3f590b34c2bff3f6f601cd0e963..9b05cd32b1eaf5af87ada4a83f5680d5f6fa42c9 100644 --- a/aleksis/core/schema/oauth.py +++ b/aleksis/core/schema/oauth.py @@ -3,28 +3,64 @@ from graphene_django import DjangoObjectType from aleksis.core.models import OAuthAccessToken, OAuthApplication -from .base import FieldFileType +from .base import ( + BaseBatchCreateMutation, + BaseBatchDeleteMutation, + BaseBatchPatchMutation, + FieldFileType, + PermissionsTypeMixin, +) -class OAuthScope(graphene.ObjectType): +class OAuthScopeType(graphene.ObjectType): name = graphene.String() description = graphene.String() -class OAuthApplicationType(DjangoObjectType): - icon = graphene.Field(FieldFileType) - +class OAuthApplicationType(PermissionsTypeMixin, DjangoObjectType): class Meta: model = OAuthApplication - fields = ["id", "name", "icon"] + fields = [ + "id", + "name", + "icon", + "client_id", + "client_secret", + "client_type", + "algorithm", + "allowed_scopes", + "redirect_uris", + "skip_authorization", + ] + + icon = graphene.Field(FieldFileType) + + @staticmethod + def resolve_algorithm(root, info, **kwargs): + """graphene-django-cud has the undocumented behavior to use "A_" instead of the empty + string""" + if not root.algorithm: + return "A_" + else: + return root.algorithm + + @staticmethod + def resolve_client_secret(root, info, **kwargs): + """Always return empty client secret as hashed client secret is no useful information.""" + return "" + + +class InitOAuthApplicationType(graphene.ObjectType): + client_id = graphene.String() + client_secret = graphene.String() class OAuthAccessTokenType(DjangoObjectType): - scopes = graphene.List(OAuthScope) + scopes = graphene.List(OAuthScopeType) @staticmethod def resolve_scopes(root: OAuthAccessToken, info, **kwargs): - return [OAuthScope(name=key, description=value) for key, value in root.scopes.items()] + return [OAuthScopeType(name=key, description=value) for key, value in root.scopes.items()] class Meta: model = OAuthAccessToken @@ -41,3 +77,42 @@ class OAuthBatchRevokeTokenMutation(graphene.Mutation): def mutate(root, info, ids): OAuthAccessToken.objects.filters(pk__in=ids, user=info.context.user).delete() return len(ids) + + +class OAuthApplicationBatchCreateMutation(BaseBatchCreateMutation): + class Meta: + model = OAuthApplication + permissions = ("core.create_oauthapplication_rule",) + only_fields = ( + "name", + "icon", + "client_id", + "client_secret", + "client_type", + "algorithm", + "allowed_scopes", + "redirect_uris", + "skip_authorization", + ) + + +class OAuthApplicationBatchDeleteMutation(BaseBatchDeleteMutation): + class Meta: + model = OAuthApplication + permissions = ("core.delete_oauthapplication_rule",) + + +class OAuthApplicationBatchPatchMutation(BaseBatchPatchMutation): + class Meta: + model = OAuthApplication + permissions = ("core.edit_oauthapplication_rule",) + only_fields = ( + "id", + "name", + "icon", + "client_type", + "algorithm", + "allowed_scopes", + "redirect_uris", + "skip_authorization", + ) diff --git a/aleksis/core/schema/personal_event.py b/aleksis/core/schema/personal_event.py index 3bf36b2e4c352cee626529002897cfc6095b6e3e..e8655d160c0841332cc0edb20764f9a53d22bae3 100644 --- a/aleksis/core/schema/personal_event.py +++ b/aleksis/core/schema/personal_event.py @@ -24,15 +24,14 @@ class PersonalEventType(DjangoObjectType): "location", "datetime_start", "datetime_end", - "timezone", "date_start", "date_end", "owner", "persons", "groups", ) - convert_choices_to_enum = False + timezone = graphene.String() recurrences = graphene.String() @@ -53,7 +52,12 @@ class PersonalEventBatchCreateMutation(PermissionBatchPatchMixin, BaseBatchCreat "persons", "groups", ) - field_types = {"recurrences": graphene.String(), "location": graphene.String()} + field_types = { + "timezone": graphene.String(), + "recurrences": graphene.String(), + "location": graphene.String(), + } + optional_fields = ("timezone", "recurrences") @classmethod def get_permissions(cls, root, info, input) -> Iterable[str]: # noqa @@ -102,10 +106,24 @@ class PersonalEventBatchPatchMutation(BaseBatchPatchMutation): "persons", "groups", ) - field_types = {"recurrences": graphene.String(), "location": graphene.String()} + field_types = { + "timezone": graphene.String(), + "recurrences": graphene.String(), + "location": graphene.String(), + } + optional_fields = ("timezone", "recurrences") @classmethod def get_permissions(cls, root, info, input, id, obj) -> Iterable[str]: # noqa if info.context.user.has_perm("core.edit_personal_event_rule", obj): return [] return cls._meta.permissions + + @classmethod + def before_mutate(cls, root, info, input): # noqa + for event in input: + # Remove recurrences if none were received. + if "recurrences" not in event: + event["recurrences"] = "" + + return input diff --git a/aleksis/core/schema/user.py b/aleksis/core/schema/user.py index 7104a64b6acfa4f0891a9371304df9609569723d..4761a1c38bc4231f9fcd2754fead27262bad9185 100644 --- a/aleksis/core/schema/user.py +++ b/aleksis/core/schema/user.py @@ -21,6 +21,8 @@ class UserType(graphene.ObjectType): ) def resolve_global_permissions_by_name(root, info, permissions, **kwargs): + if root.is_anonymous: + return [{"name": permission_name, "result": False} for permission_name in permissions] return [ {"name": permission_name, "result": info.context.user.has_perm(permission_name)} for permission_name in permissions diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index a8f35f837c58b62cd0cd5bc111958f7f4aaa8a10..7dab74e79e617436ea09aa08e9100d794421c2d7 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -268,7 +268,7 @@ VALKEY_PASSWORD = REDIS_PASSWORD = _VALKEY.get("password", None) VALKEY_USER = REDIS_USER = _VALKEY.get("user", None if VALKEY_PASSWORD is None else "default") VALKEY_URL = REDIS_URL = ( - f"redis://{VALKEY_USER+':'+VALKEY_PASSWORD+'@' if VALKEY_USER else ''}" + f"redis://{VALKEY_USER + ':' + VALKEY_PASSWORD + '@' if VALKEY_USER else ''}" f"{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" ) @@ -376,7 +376,7 @@ ACCOUNT_EMAIL_SUBJECT_PREFIX = _settings.get("auth.registration.subject", "[Alek ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True # Enforce uniqueness of email addresses -ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", True) +ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.registration.unique_email", True) # Configurable username validators ACCOUNT_USERNAME_VALIDATORS = "aleksis.core.util.auth_helpers.custom_username_validators" diff --git a/aleksis/core/static/public/theme.scss b/aleksis/core/static/public/theme.scss index 1cddad1c4bd5b3101779a4ba91c53b7c22124176..894450e299f5f87c19488d956139380d579f2890 100644 --- a/aleksis/core/static/public/theme.scss +++ b/aleksis/core/static/public/theme.scss @@ -295,8 +295,9 @@ $toast-action-color: #eeff41; // 20. Typography // ========================================================================== -$font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, - Ubuntu, Cantarell, "Helvetica Neue", sans-serif !default; +$font-stack: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, + Cantarell, "Helvetica Neue", sans-serif !default; $off-black: rgba(0, 0, 0, 0.87) !default; // Header Styles $h1-fontsize: 4.2rem !default; diff --git a/aleksis/core/templates/core/announcement/form.html b/aleksis/core/templates/core/announcement/form.html deleted file mode 100644 index 60d573d0f0de3cc2f4e3e8409beab1716690715c..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/announcement/form.html +++ /dev/null @@ -1,39 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load i18n material_form any_js %} - -{% block extra_head %} - {{ form.media.css }} - {% include_css "select2-materialize" %} -{% endblock %} - -{% block browser_title %} - {% if mode == "edit" %} - {% blocktrans %}Edit announcement{% endblocktrans %} - {% else %} - {% blocktrans %}Publish announcement{% endblocktrans %} - {% endif %} -{% endblock %} -{% block page_title %} - {% if mode == "edit" %} - {% blocktrans %}Edit announcement{% endblocktrans %} - {% else %} - {% blocktrans %}Publish new announcement{% endblocktrans %} - {% endif %} -{% endblock %} - -{% block content %} - <form action="" method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - - <button type="submit" class="btn green waves-effect waves-light"> - <i class="material-icons left iconify" data-icon="mdi:content-save-outline"></i> - {% trans "Save and publish announcement" %} - </button> - </form> - {% include_js "select2-materialize" %} - {{ form.media.js }} -{% endblock %} diff --git a/aleksis/core/templates/core/announcement/list.html b/aleksis/core/templates/core/announcement/list.html deleted file mode 100644 index 763f4f10cce6c240f80812502c7a378bbdb3a0f7..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/announcement/list.html +++ /dev/null @@ -1,56 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load i18n %} - -{% block browser_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Announcements{% endblocktrans %}{% endblock %} - -{% block content %} - <a class="btn green waves-effect waves-light" href="{% url "add_announcement" %}"> - <i class="material-icons left iconify" data-icon="mdi:add"></i> - {% trans "Publish new announcement" %} - </a> - <table class="highlight"> - <thead> - <tr> - <th>{% trans "Title" %}</th> - <th>{% trans "Valid from" %}</th> - <th>{% trans "Valid until" %}</th> - <th>{% trans "Recipients" %}</th> - <th>{% trans "Actions" %}</th> - </tr> - </thead> - <tbody> - {% for announcement in announcements %} - <tr> - <td>{{ announcement.title }}</td> - <td>{{ announcement.valid_from }}</td> - <td>{{ announcement.valid_until }}</td> - <td>{{ announcement.recipients.all|join:", " }}</td> - <td> - <a class="btn-flat waves-effect waves-orange orange-text" - href="{% url "edit_announcement" announcement.id %}"> - <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i> - {% trans "Edit" %} - </a> - <form action="{% url "delete_announcement" announcement.id %}" method="post"> - {% csrf_token %} - <button class="btn-flat waves-effect waves-re red-text" type="submit"> - <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i> - {% trans "Delete" %} - </button> - </form> - </td> - </tr> - {% empty %} - <tr> - <td colspan="5"> - <p class="flow-text center-align">{% trans "There are no announcements." %}</p> - </td> - </tr> - {% endfor %} - </tbody> - </table> -{% endblock %} diff --git a/aleksis/core/templates/oauth2_provider/application/create.html b/aleksis/core/templates/oauth2_provider/application/create.html deleted file mode 100644 index 683c81fa25966dea43c5737537ff090b4b042f01..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/application/create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n material_form %} - -{% block browser_title %}{% blocktrans %}Register OAuth2 Application{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Register OAuth2 Application{% endblocktrans %}{% endblock %} - -{% block content %} - <form method="post" enctype="multipart/form-data"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - <a class="btn waves-effect red waves-light" href="{% url "oauth2_applications" %}"> - <i class="material-icons iconify left" data-icon="mdi:close"></i> {% trans "Cancel" %} - </a> - </form> -{% endblock %} diff --git a/aleksis/core/templates/oauth2_provider/application/detail.html b/aleksis/core/templates/oauth2_provider/application/detail.html deleted file mode 100644 index 1a7709f14dacdef5ab414d516ec35d72cbc1e5f8..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/application/detail.html +++ /dev/null @@ -1,87 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n %} - -{% block browser_title %}{% blocktrans %}OAuth2 Application{% endblocktrans %}{% endblock %} -{% block page_title %} - <a href="{% url "oauth2_applications" %}" - class="btn-flat primary-color-text waves-light waves-effect"> - <i class="material-icons iconify left" data-icon="mdi:chevron-left"></i> {% trans "Back" %} - </a> - {{ application.name }} -{% endblock %} - -{% block content %} - <a class="btn orange waves-effect waves-light btn-margin" href="{% url "edit_oauth2_application" application.id %}"> - <i class="material-icons iconify left" data-icon="mdi:pencil-outline"></i> - {% trans "Edit" %} - </a> - <a class="btn red waves-effect waves-light btn-margin" href="{% url "delete_oauth2_application" application.id %}"> - <i class="material-icons iconify left" data-icon="mdi:delete-outline"></i> - {% trans "Delete" %} - </a> - <table class="responsive-table"> - <tbody> - <tr> - <th>{% trans "Icon" %}</th> - <td> - {% if application.icon %} - <div class="application-circle materialboxed z-depth-2"> - <img src="{{ application.icon.url }}" alt="{{ oauth_application.name }}" class="hundred-percent"> - </div> - {% else %} - – - {% endif %} - </td> - </tr> - <tr> - <th> - {% trans "Client id" %} - </th> - <td> - <code class="break-word">{{ application.client_id }}</code> - </td> - </tr> - <tr> - <th> - {% trans "Client secret" %} - </th> - <td> - <code class="break-word">{{ application.client_secret }}</code> - </td> - </tr> - <tr> - <th> - {% trans "Client type" %} - </th> - <td> - {{ application.client_type }} - </td> - </tr> - <tr> - <th> - {% trans "Allowed scopes" %} - </th> - <td> - {{ application.allowed_scopes|join:", " }} - </td> - </tr> - <tr> - <th> - {% trans "Redirect URIs" %} - </th> - <td> - {{ application.redirect_uris }} - </td> - </tr> - <tr> - <th> - {% trans "Skip Authorisation" %} - </th> - <td> - <i class="material-icons iconify" data-icon="mdi:{{ application.skip_authorization|yesno:"check,close" }}"></i> - </td> - </tr> - </tbody> - </table> -{% endblock %} diff --git a/aleksis/core/templates/oauth2_provider/application/edit.html b/aleksis/core/templates/oauth2_provider/application/edit.html deleted file mode 100644 index e4d837a32d713ef07b05656425e97979efef92cd..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/application/edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n material_form %} - -{% block browser_title %}{% blocktrans %}Edit OAuth2 Application{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Edit OAuth2 Application{% endblocktrans %}{% endblock %} - -{% block content %} - <form method="post" enctype="multipart/form-data"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - <a class="btn waves-effect red waves-light" href="{% url "oauth2_application" application.id %}"> - <i class="material-icons iconify left" data-icon="mdi:close"></i> {% trans "Cancel" %} - </a> - </form> -{% endblock %} diff --git a/aleksis/core/templates/oauth2_provider/application/list.html b/aleksis/core/templates/oauth2_provider/application/list.html deleted file mode 100644 index 1525dc99c28326701d5b91a59c1992a23abff439..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/oauth2_provider/application/list.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "core/base.html" %} - -{% load i18n %} - -{% block browser_title %}{% blocktrans %}OAuth2 Applications{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}OAuth2 Applications{% endblocktrans %}{% endblock %} - -{% block content %} - <a href="{% url "register_oauth_application" %}" class="btn green waves-effect waves-light"> - <i class="material-icons iconify left" data-icon="mdi:add"></i> - {% blocktrans %}Register new application{% endblocktrans %} - </a> - <div class="collection"> - {% for application in applications %} - <a class="collection-item avatar" href="{% url "oauth2_application" application.id %}"> - {% if application.icon %} - <img src="{{ application.icon.url }}" alt="{{ application.name }}" class="circle"> - {% endif %} - <span class="title"> - {{ application.name }} - </span> - </a> - {% empty %} - <div class="collection-item flow-text"> - {% blocktrans %}No applications defined.{% endblocktrans %} - </div> - {% endfor %} - </div> -{% endblock %} diff --git a/aleksis/core/templates/templated_email/email.css b/aleksis/core/templates/templated_email/email.css index a0ba968138025bfd0e32eeb7ce42d5228334bfb3..75f645fd2fa2a34ed5b1c04b238fa33480f811d7 100644 --- a/aleksis/core/templates/templated_email/email.css +++ b/aleksis/core/templates/templated_email/email.css @@ -1,7 +1,8 @@ body { line-height: 1.5; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, + Cantarell, "Helvetica Neue", sans-serif; font-weight: normal; color: rgba(0, 0, 0, 0.87); display: flex; diff --git a/aleksis/core/tests/regression/test_regression.py b/aleksis/core/tests/regression/test_regression.py index a578145dba3bf3c98ccffb8df00a4f78e3c0b7ee..8cc5e67cc92e84bba6968382b41a32db4257908f 100644 --- a/aleksis/core/tests/regression/test_regression.py +++ b/aleksis/core/tests/regression/test_regression.py @@ -1,12 +1,16 @@ import base64 +import json +from datetime import date from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse import pytest +from graphene_django.utils.testing import graphql_query +from graphql import GraphQLError -from aleksis.core.models import Group, OAuthApplication, Person +from aleksis.core.models import Group, Holiday, OAuthApplication, Person pytestmark = pytest.mark.django_db @@ -147,3 +151,111 @@ def test_change_password_not_logged_in(client): assert response.status_code == 200 assert "Please login to see this page." in response.content.decode("utf-8") + + +def test_nested_queries_max_depth(logged_in_client): + """Tests that queries enforces a maximum depth limit. + + https://edugit.org/AlekSIS/official/AlekSIS-Core/-/issues/1195 + """ + + with pytest.raises(GraphQLError) as excinfo: + graphql_query( + """ + query person{ + personByIdOrMe{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + } + } + } + } + } + } + } + } + } + } + } + } + } + } + """, + client=logged_in_client, + ) + + assert "exceeds maximum operation depth" in str(excinfo.value) + + response = graphql_query( + """ + query person{ + personByIdOrMe{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + memberOf{ + id + members{ + id + } + } + } + } + } + } + } + } + } + } + """, + client=logged_in_client, + ) + + content = json.loads(response.content) + assert "errors" not in content + + +def test_all_day_events_end_date(): + """Tests that CalendarEvent's value method returns a non-inclusive end date for all day events. + + https://edugit.org/AlekSIS/official/AlekSIS-Core/-/issues/1199 + """ + holiday = Holiday.objects.create( + date_start=date(2024, 2, 1), date_end=date(2024, 2, 1), holiday_name="Test Holiday" + ) + + assert holiday.value_start_datetime(holiday) == date(2024, 2, 1) + assert holiday.value_end_datetime(holiday) == date(2024, 2, 2) diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 2949d4fa0ce892caa7364f34e451c0af3985f7a5..fa966c1b134ae14a62a87917624f1b7aac97138b 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -39,6 +39,16 @@ urlpatterns = [ ConnectDiscoveryInfoView.as_view(), name="oidc_configuration", ), + path( + ".well-known/carddav/", + views.DAVWellKnownView.as_view(), + name="wellknown_carddav", + ), + path( + ".well-known/caldav/", + views.DAVWellKnownView.as_view(), + name="wellknown_caldav", + ), path("oauth/applications/", views.TemplateView.as_view(template_name="core/vue_index.html")), path( "oauth/applications/register/", @@ -76,6 +86,27 @@ urlpatterns = [ views.ObjectRepresentationView.as_view(), name="object_representation_anonymous", ), + path( + "feeds/<str:subregistry>/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed" + ), + path("feeds/<str:name>.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"), + path("dav/", views.DAVResourceView.as_view(), name="dav_registry"), + path("dav/<str:name>/", views.DAVResourceView.as_view(), name="dav_subregistry"), + path( + "dav/<str:subregistry>/<str:name>/", + views.DAVResourceView.as_view(), + name="dav_resource", + ), + path( + "dav/<str:subregistry>/<str:name>/<int:id>.ics", + views.DAVSingleResourceView.as_view(), + name="dav_resource_calendar", + ), + path( + "dav/<str:subregistry>/<str:name>/<int:id>.vcf", + views.DAVSingleResourceView.as_view(), + name="dav_resource_contact", + ), path("", include("django_prometheus.urls")), path( "django/", @@ -168,47 +199,10 @@ urlpatterns = [ path("groups/<int:id_>/edit/", views.edit_group, name="edit_group_by_id"), path("", views.index, name="index"), path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"), - path("announcements/", views.announcements, name="announcements"), - path("announcements/create/", views.announcement_form, name="add_announcement"), - path( - "announcements/edit/<int:id_>", - views.announcement_form, - name="edit_announcement", - ), - path( - "announcements/delete/<int:id_>", - views.delete_announcement, - name="delete_announcement", - ), path("search/searchbar/", views.searchbar_snippets, name="searchbar_snippets"), path("search/", views.PermissionSearchView.as_view(), name="haystack_search"), path("maintenance-mode/", include("maintenance_mode.urls")), path("impersonate/", include("impersonate.urls")), - path( - "oauth/applications/", - views.OAuth2ListView.as_view(), - name="oauth2_applications", - ), - path( - "oauth/applications/register/", - views.OAuth2RegisterView.as_view(), - name="register_oauth_application", - ), - path( - "oauth/applications/<int:pk>", - views.OAuth2DetailView.as_view(), - name="oauth2_application", - ), - path( - "oauth/applications/<int:pk>/delete/", - views.OAuth2DeleteView.as_view(), - name="delete_oauth2_application", - ), - path( - "oauth/applications/<int:pk>/edit/", - views.OAuth2EditView.as_view(), - name="edit_oauth2_application", - ), path( "oauth/authorize/", views.CustomAuthorizationView.as_view(), @@ -387,8 +381,6 @@ 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/auth_helpers.py b/aleksis/core/util/auth_helpers.py index eaeb0030c0b9040b28d719b96af2d02f80aa7952..5c76860964077ae6fa588349084979c6d9b5e711 100644 --- a/aleksis/core/util/auth_helpers.py +++ b/aleksis/core/util/auth_helpers.py @@ -1,11 +1,13 @@ """Helpers/overrides for django-allauth.""" +from base64 import b64decode from typing import Any, Optional +from django.contrib.auth import authenticate from django.contrib.auth.validators import ASCIIUsernameValidator from django.core.exceptions import ValidationError from django.core.validators import RegexValidator -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest from django.utils.translation import gettext_lazy as _ from oauth2_provider.models import AbstractApplication @@ -124,6 +126,49 @@ class ClientProtectedResourceMixin(_ClientProtectedResourceMixin): return required_scopes.issubset(allowed_scopes) +class BasicAuthMixin: + """Mixin for protecting views using HTTP Basic Auth. + + Useful for views being used outside a regular session authenticated, e.g. a + CalDAV client providing only username/password authentication. For its + `dispatch` method to be called, the BasicAuthMixin has to be the first + parent class. Make sure to call `super().dispatch(self, *args, **kwargs)` + if you implement a custom dispatch method. + """ + + def dispatch(self, request, *args, **kwargs): + if request.user is not None and request.user.is_authenticated: + return super().dispatch(request, *args, **kwargs) + + auth_header = request.headers.get("Authorization") + + if auth_header is None or not auth_header.startswith("Basic "): + return HttpResponse( + "Unauthorized", + status=401, + headers={"WWW-Authenticate": 'Basic realm="AlekSIS", charset="utf-8"'}, + ) + + auth_data = auth_header.removeprefix("Basic ") + try: + auth_data_decoded = b64decode(auth_data).decode("ascii") + except UnicodeDecodeError: + try: + auth_data_decoded = b64decode(auth_data).decode("utf-8") + except UnicodeDecodeError: + return HttpResponseBadRequest( + "HTTP Basic Auth credentials must be encoded in UTF-8 or ASCII" + ) + username, password = auth_data_decoded.split(":", 1) + user = authenticate(request, username=username, password=password) + + if user is not None: + request.user = user + return super().dispatch(request, *args, **kwargs) + else: + return HttpResponse("Unauthorized", status=401) + + def validate_username_preference_regex(value: str): regex = get_site_preferences()["auth__allowed_username_regex"] return RegexValidator(regex)(value) diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index 3a6aa841a4d26823e24c18a34b6e9ca50a4bfff7..7b3921f63af581d071f076f69e4f32ce7d575c23 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -24,7 +24,7 @@ from django.utils.module_loading import import_string import django_ical.feedgenerator as feedgenerator import recurring_ical_events from cache_memoize import cache_memoize -from icalendar import Calendar, Event, Todo +from icalendar import Calendar, Component, Event, Timezone, Todo if TYPE_CHECKING: from django.contrib.contenttypes.models import ContentType @@ -490,37 +490,92 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed): Adds a method to return the actual calendar object. """ - def get_calendar_object(self, with_meta: bool = True, with_reference_object: bool = False): + def _is_unrequested_prop( + self, element: Component, efield: str, params: Optional[dict[str, any]] = None + ): + """Return True if specific fields are requested and efield is not one of those. + If no fields are specified or efield is requested, return False""" + + return ( + params is not None + and element.name in params + and efield is not None + and efield.upper() not in params[element.name] + ) + + def get_calendar_object( + self, + with_meta: bool = True, + with_reference_object: bool = False, + params: dict[str, any] = None, + ): cal = Calendar() - cal.add("version", "2.0") - cal.add("calscale", "GREGORIAN") - cal.add("prodid", "-//AlekSIS//AlekSIS//EN") + cal_props = { + "version": "2.0", + "calscale": "GREGORIAN", + "prodid": "-//AlekSIS//AlekSIS//EN", + } + for efield, val in cal_props.items(): + if self._is_unrequested_prop(cal, efield, params): + continue + + cal.add(efield, val) for ifield, efield in EXTENDED_FEED_FIELD_MAP: + if self._is_unrequested_prop(cal, efield, params): + continue + val = self.feed.get(ifield) - if val is not None: + if val: cal.add(efield, val) - self.write_items(cal, with_meta=with_meta, with_reference_object=with_reference_object) + self.write_items( + cal, with_meta=with_meta, with_reference_object=with_reference_object, params=params + ) + + if params is not None and "VTIMEZONE" in params and params["VTIMEZONE"]: + cal.add_missing_timezones() return cal - def write(self, outfile, encoding): - cal = self.get_calendar_object(with_meta=False) + def to_ical(self, params: Optional[dict[str, any]] = None): + cal = self.get_calendar_object(with_meta=False, params=params) to_ical = getattr(cal, "as_string", None) if not to_ical: to_ical = cal.to_ical - outfile.write(to_ical()) + return to_ical() + + def write(self, outfile, params: Optional[dict[str, any]] = None): + cal = self.get_calendar_object(with_meta=False, params=params) + + to_ical = getattr(cal, "as_string", None) + if not to_ical: + to_ical = cal.to_ical + outfile.write(self.to_ical()) + + def write_items( + self, + calendar, + with_meta: bool = True, + with_reference_object: bool = False, + params: dict[str, any] = None, + ): + if params is not None and "timezone" in params: + tz = Timezone.from_ical(params["timezone"]) + else: + tz = None - def write_items(self, calendar, with_meta: bool = True, with_reference_object: bool = False): for item in self.items: component_type = item.get("component_type") element = Todo() if component_type == "todo" else Event() for ifield, efield in EXTENDED_ITEM_ELEMENT_FIELD_MAP: + if self._is_unrequested_prop(element, efield, params): + continue + val = item.get(ifield) - if val is not None: + if val: if ifield == "attendee": for list_item in val: element.add(efield, list_item) @@ -533,10 +588,35 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed): elif ifield == "reference_object": if with_reference_object: element.add(efield, val, encode=False) + elif ( + ifield == "start_datetime" + or ifield == "end_datetime" + or ifield == "timestamp" + or ifield == "created" + or ifield == "updateddate" + or ifield == "rdate" + or ifield == "exdate" + ): + if tz is not None: + val = val.astimezone(tz.to_tz()) + element.add(efield, val) else: element.add(efield, val) calendar.add_component(element) + if params is not None and ("expand_start" in params and "expand_end" in params): + recurrences = self.get_single_events( + start=params["expand_start"], + end=params["expand_end"], + ) + + for event in recurrences: + props = list(event.keys()) + for prop in props: + if self._is_unrequested_prop(event, prop, params): + event.pop(prop) + calendar.add_component(event) + def get_single_events(self, start=None, end=None, with_reference_object: bool = False): """Get single event objects for this feed.""" events = recurring_ical_events.of( diff --git a/aleksis/core/util/dav_handler/__init__.py b/aleksis/core/util/dav_handler/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7a36d7c4bf82132a99de9fd73e365021d06169ee --- /dev/null +++ b/aleksis/core/util/dav_handler/__init__.py @@ -0,0 +1 @@ +from . import calendar, contact, generic # noqa: F401 diff --git a/aleksis/core/util/dav_handler/base.py b/aleksis/core/util/dav_handler/base.py new file mode 100644 index 0000000000000000000000000000000000000000..457365fa35d74f953fd7474cfc2fd15399ec4fec --- /dev/null +++ b/aleksis/core/util/dav_handler/base.py @@ -0,0 +1,343 @@ +import warnings +from typing import Optional +from xml.etree import ElementTree +from xml.sax.handler import ContentHandler, feature_namespaces +from xml.sax.xmlreader import InputSource + +from django.core.exceptions import BadRequest +from django.http import Http404, HttpRequest +from django.urls import reverse + +from defusedxml.sax import make_parser + +from ...mixins import DAVResource, RegistryObject + + +class ElementHandler(RegistryObject, is_registry=True): + """Abstract class serving as registry for ElementHandlers. + + While parsing an XML document, for every XML element a handler exists for, + an instance of the respective sub class is created. By using the `children` + attribute, the tree is represented for later processing and creating the + response. Using `invisible` causes the Element not to be included in XML. + """ + + invisible: bool = False + + def __init__( + self, + request: "DAVRequest", + parent: "ElementHandler", + attrs, + invisible: Optional[bool] = None, + ): + self.request = request + self.parent = parent + self.attrs = attrs + self.content = "" + if invisible is not None: + self.invisible = invisible + else: + self.invisible = self.__class__.invisible + self.children = [] + + @classmethod + def get_name(cls, attrs): + return cls.name + + def continueToChild(self, child_class, attrs, invisible: Optional[bool] = None): + """Create instance of ElementHandler `child_class` and append to + children or return existing child.""" + + child = child_class(self.request, self, attrs, invisible=invisible) + self.children.append(child) + self.request.current_object = child + + return child + + def process(self, stage: str, base: "DAVMultistatus", response: "DAVResponse" = None): + """Build elements of the DAV multistatus response.""" + + previous_xml = base.current_xml + + if not self.invisible: + xml_element = base.current_xml.find(self.name) + if xml_element is not None: + base.current_xml = xml_element + + stage_method_name = f"process_{stage}" + if hasattr(self, stage_method_name) and callable( + stage_method := getattr(self, stage_method_name) + ): + try: + stage_method(base, response) + except Http404: + response.handle_unknown(self.name) + + for child in self.children: + child.process(stage, base, response) + + base.current_xml = previous_xml + + def process_xml(self, base: "DAVMultistatus", response: "DAVResponse" = None): + """Add XML element representing self to multistatus XML tree.""" + + if not self.invisible: + base.current_xml = ElementTree.SubElement(base.current_xml, self.name) + + @staticmethod + def _get_xml_sub(name): + """Convert a tuple like ("DAV:", "prop") to a string like "{DAV:}prop".""" + + return f"{{{name[0]}}}{name[1]}" + + +class DAVRequest(ElementHandler, ContentHandler): + """Handler that processes a DAV Request by parsing its XML tree. + + Based on the `resource`, `obj` and `depth`, `resources` and `objects` to be + included the response are determined. Furthermore, `ElementHandler`s may + modify those. + """ + + depth: int | None + + def __init__( + self, http_request: HttpRequest, resource: type[DAVResource], obj: Optional[DAVResource] + ): + super().__init__(self, None, {}) + self._request = http_request + + if depth := self._request.headers.get("Depth", "infinity"): + if depth not in ("0", "1", "infinity"): + raise BadRequest("Depth must be 0, 1 or infinity") + elif depth == "infinity": + self.depth = None + else: + self.depth = int(depth) + + self.current_object = self + + self.resource = resource + self.resources = [] + self.objects = [] + + _is_registry = getattr(self.resource, "_is_registry", False) + + if obj is None: + self.resources.append(self.resource) + + if self.depth != 0 and _is_registry: + resources = [] + if self.resource == DAVResource: + resources += self.resource.get_sub_registries().values() + if self.resource != DAVResource or self.depth is None: + if hasattr(self.resource, "valid_feeds"): + resources += self.resource.valid_feeds + else: + resources += self.resource.get_registry_objects().values() + + for rcls in resources: + self.resources.append(rcls) + + else: + self.objects.append(obj) + + def parse(self): + """Start parsing the event-based XML parser.""" + + parser = make_parser() + parser.setFeature(feature_namespaces, True) + parser.setContentHandler(self) + + source = InputSource() + source.setByteStream(self._request) + return parser.parse(source) + + def startElementNS(self, name, qname, attrs): + """Handle start of a new XML element and continue to respective handler. + + `ElementHandler`s may implement a `pre_handle` method to be called at + the start of an element. + """ + + xml_sub = self._get_xml_sub(name) + obj = self.get_object_by_name(xml_sub) + if obj is not None: + self.current_object.continueToChild(obj, attrs.copy()) + if hasattr(obj, "pre_handle"): + self.current_object.pre_handle() + else: + child = NotImplementedObject(xml_sub) + self.current_object.children.append(child) + warnings.warn(f"DAVRequest could not parse {xml_sub}.") + + def endElementNS(self, name, qname): + """Handle end of an XML element. + + `ElementHandler`s may implement a `post_handle` method to be called at + the end of an element. + """ + + if self.current_object.name == self._get_xml_sub(name): + if hasattr(self.current_object, "post_handle"): + self.current_object.post_handle() + self.current_object = self.current_object.parent + + def characters(self, content): + """Handle content of an XML element.""" + + self.current_object.content += content + + +class DAVMultistatus: + """Base of a DAV multistatus response. + + Processes children of DAVRequest `request` to build response XML. + """ + + name = "{DAV:}multistatus" + + def __init__(self, request: DAVRequest): + self.request = request + + self.request.resource._register_dav_ns() + self.xml_element = ElementTree.Element(self.name) + self.current_xml = self.xml_element + + def process(self): + """Call process stages of all `children` of a `DAVRequest`. + + There are the following stages, optionally implemented + by ElementHandlers using methods named `process_{stage}`: + `xml`: Build the response by adding an element to the XML tree. + """ + + for stage in ("xml",): + for child in self.request.children: + child.process(stage, self) + + +class NotImplementedObject: + """Class to represent requested props that are not implemented.""" + + def __init__(self, xml_sub): + self.xml_sub = xml_sub + + def process(self, stage, base, response=None): + if stage == "xml" and response is not None: + response.handle_unknown(self.xml_sub) + + +class NotFoundObject: + """Class to represent requested objects that do not exist.""" + + def __init__(self, href): + self.href = href + + +class DAVResponse: + """Part of a `DAVMultistatus` containing props of a single `resource` or `obj`.""" + + name = "{DAV:}response" + + def __init__(self, base: "DAVMultistatus", resource, obj): # noqa: F821 + self.base = base + self.resource = resource + self.obj = obj + + self.xml_element = ElementTree.SubElement(self.base.current_xml, self.name) + + self.href = ElementTree.SubElement(self.xml_element, "{DAV:}href") + + if obj is None: + if self.resource == DAVResource: + self.href.text = reverse("dav_registry") + elif getattr(self.resource, "_is_registry", False): + self.href.text = reverse("dav_subregistry", args=[self.resource.name]) + else: + self.href.text = reverse( + "dav_resource", args=[self.resource.__bases__[0].name, self.resource.name] + ) + else: + if isinstance(obj, NotFoundObject): + self.href.text = obj.href + else: + self.href.text = self.resource.get_dav_absolute_url( + self.obj, self.base.request._request + ) + + self.propstats = {} + + if isinstance(obj, NotFoundObject): + status = ElementTree.SubElement(self.xml_element, "{DAV:}status") + status.text = "HTTP/1.1 404 Not Found" + else: + self.init_propstat(200, "HTTP/1.1 200 OK") + + def init_propstat(self, code, status_text): + """Initialize common sub elements of a DAV response.""" + + propstat = ElementTree.SubElement(self.xml_element, "{DAV:}propstat") + status = ElementTree.SubElement(propstat, "{DAV:}status") + status.text = status_text + + self.propstats[code] = propstat + return propstat + + def handle_unknown(self, xml_sub): + """Handle element for which no `ElementHandler` exists.""" + + if 404 not in self.propstats: + propstat = self.init_propstat(404, "HTTP/1.1 404 Not Found") + ElementTree.SubElement(propstat, "{DAV:}prop") + + propstat = self.propstats[404] + prop = propstat.find("{DAV:}prop") + ElementTree.SubElement(prop, xml_sub) + + +class QueryBase: + """Mixin for REPORT or PROPFIND query `ElementHandler`s. + + Creates `DAVResponse` objects for `resources` and `objects` of a + `DAVRequest` to be processed in order to build the response. + """ + + def process(self, stage: str, base): + """Custom process method calling `children`'s process methods for each + response.""" + + previous_xml = base.current_xml + + stage_method_name = f"process_{stage}" + if hasattr(self, stage_method_name) and callable( + stage_method := getattr(self, stage_method_name) + ): + stage_method(base) + + for response in self.responses: + if isinstance(response.obj, NotFoundObject): + continue + + base.current_xml = response.propstats[200] + for child in self.children: + child.process(stage, base, response) + + base.current_xml = previous_xml + + def process_xml(self, base): + self.responses = [] + for resource in base.request.resources: + response = DAVResponse(base, resource, None) + self.responses.append(response) + + for obj in base.request.objects: + response = DAVResponse(base, base.request.resource, obj) + self.responses.append(response) + + def post_handle(self): + if self.request.objects is None: + self.request.objects = self.request.resource.get_objects( + request=self.request._request, start_qs=self.request.objects + ) diff --git a/aleksis/core/util/dav_handler/calendar.py b/aleksis/core/util/dav_handler/calendar.py new file mode 100644 index 0000000000000000000000000000000000000000..8f3c6220caa08466171c7b0ae5d4640db474c53a --- /dev/null +++ b/aleksis/core/util/dav_handler/calendar.py @@ -0,0 +1,267 @@ +from datetime import datetime +from xml.etree import ElementTree + +from django.db.models import Q +from django.http import Http404 +from django.urls import reverse + +from ..core_helpers import EXTENDED_ITEM_ELEMENT_FIELD_MAP +from .base import ElementHandler, QueryBase +from .generic import DAVHref, DAVProp + + +class TimeRangeFilter(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}time-range" + invisible = True + + def post_handle(self): + report_base = next(iter(self.request.children)) + + for k, v in self.attrs.items(): + if k in [(None, "start"), (None, "end")]: + d = datetime.fromisoformat(v) + report_base.get_objects_args[k[1]] = d + + +class TextMatch(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}text-match" + invisible = True + + def _matches(self, obj, field_name): + method_name = f"value_{field_name}" + if not hasattr(obj, method_name) or not callable(getattr(obj, method_name)): + return False + + return self.content.lower() in getattr(obj, method_name)(obj) # FIXME: Collations + + def post_handle(self): + field_name = self.parent._get_field_name(self.parent.attrs.get((None, "name"))) + objs = [ + obj.pk + for obj in filter(lambda obj: self._matches(obj, field_name), self.request.objects) + ] + q = Q(pk__in=[objs]) + + report_base = next(iter(self.request.children)) + + if "additional_filter" in report_base.get_objects_args: + q = report_base.get_objects_args["additional_filter"] & q + + report_base.get_objects_args["additional_filter"] = q + + +class PropFilter(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}prop-filter" + invisible = True + + @staticmethod + def _get_field_name(ical_name): + fields = list(filter(lambda f: f[1] == ical_name.lower(), EXTENDED_ITEM_ELEMENT_FIELD_MAP)) + try: + return fields.pop()[0] + except StopIteration: + return None + + +class CalDAVProp(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}prop" + invisible = True + + @classmethod + def get_name(cls, attrs): + name = attrs.get((None, "name")) + return f"{cls.name}-{name}" + + def _get_calendar_data(self, parent): + """Helper method to find the base `CalendarData` instance.""" + + if isinstance(parent, CalendarData): + return parent + return self._get_calendar_data(parent.parent) + + def pre_handle(self): + calendar_data = self._get_calendar_data(self.parent.parent) + comp_name = self.parent.attrs.get((None, "name")) + prop = self.attrs.get((None, "name")) + calendar_data.params.setdefault(comp_name, []).append(prop) + + +class CalDAVFilter(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}filter" + invisible = True + + def pre_handle(self): + self.filters = {} + + +class CalDAVCompFilter(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}comp-filter" + invisible = True + + +class CalDAVComp(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}comp" + invisible = True + + @classmethod + def get_name(cls, attrs): + name = attrs.get((None, "name")) + return f"{cls.name}-{name}" + + def pre_handle(self): + if self.attrs.get((None, "name")) == "VTIMEZONE": + self.parent.parent.params["VTIMEZONE"] = True + + +class Expand(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}expand" + + def post_handle(self): + for k, v in self.attrs.items(): + if k in [(None, "start"), (None, "end")]: + d = datetime.fromisoformat(v) + self.parent.params[f"expand_{k[1]}"] = d + + +class CalendarData(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}calendar-data" + + def pre_handle(self): + self.params = {} + + def post_handle(self): + if not self.params: + attrs = {(None, "name"): "VCALENDAR"} + vcalendar = CalDAVComp(self.request, self, attrs) + self.children.append(vcalendar) + + self.params["VTIMEZONE"] = True + + for comp_name in ("VTIMEZONE", "VEVENT"): + attrs = {(None, "name"): comp_name} + comp = CalDAVComp(self.request, self, attrs) + vcalendar.children.append(comp) + + def process_xml(self, base, response): + super().process_xml(base, response) + if not self.invisible: + if response.obj is not None: + objects = response.resource.objects.filter(pk=response.obj.pk) + else: + objects = [] + + ical = response.resource.get_dav_file_content( + base.request._request, objects, params=self.params + ) + base.current_xml.text = ical.decode() + + +class CalendarColor(ElementHandler): + name = "{http://apple.com/ns/ical/}calendar-color" + + def process_xml(self, base, response): + if not hasattr(response.resource, "get_color"): + raise Http404 + + super().process_xml(base, response) + base.current_xml.text = response.resource.get_color() + + +class CalendarDescription(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}calendar-description" + + def process_xml(self, base, response): + if not hasattr(response.resource, "get_description"): + raise Http404 + + super().process_xml(base, response) + base.current_xml.text = response.resource.get_description() + + +class CalendarHomeSet(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}calendar-home-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + href = ElementTree.SubElement(base.current_xml, "{DAV:}href") + href.text = reverse("dav_subregistry", args=["calendar"]) + + +class SupportedCalendarComponentSet(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + comp = ElementTree.SubElement(base.current_xml, "{urn:ietf:params:xml:ns:caldav}comp") + comp.set("name", "VEVENT") + + +class SupportedCollationSet(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}supported-collation-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + + supported_collations = [ + "i;ascii-casemap", + "i;octet", + ] + + for collation in supported_collations: + supported_collation = ElementTree.SubElement( + base.current_xml, "{urn:ietf:params:xml:ns:caldav}supported-collation" + ) + supported_collation.text = collation + + +class Timezone(ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}timezone" + invisible = True + + +class ReportBase(QueryBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.get_objects_args = { + "request": self.request._request, + } + + def post_handle(self): + super().post_handle() + + self.request.objects = self.request.resource.get_objects(**self.get_objects_args) + + try: + timezone = next(filter(lambda child: isinstance(child, Timezone), self.children)) + except StopIteration: + timezone = None + + if timezone is not None: + prop = next(filter(lambda child: isinstance(child, DAVProp), self.children)) + calendar_data = next( + filter(lambda child: isinstance(child, CalendarData), prop.children) + ) + calendar_data.params["timezone"] = timezone.content + + +class CalendarQuery(ReportBase, ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}calendar-query" + + def pre_handle(self): + self.request.resources = [] + + +class CalendarMultiget(ReportBase, ElementHandler): + name = "{urn:ietf:params:xml:ns:caldav}calendar-multiget" + + def pre_handle(self): + self.request.resources = [] + self.request.objects = [] + + def post_handle(self): + super().post_handle() + + for child in self.children: + if child.name == DAVHref.name: + child.invisible = True diff --git a/aleksis/core/util/dav_handler/contact.py b/aleksis/core/util/dav_handler/contact.py new file mode 100644 index 0000000000000000000000000000000000000000..f6f7d84e12ef960a60a4436692aaca4e0e2cb623 --- /dev/null +++ b/aleksis/core/util/dav_handler/contact.py @@ -0,0 +1,99 @@ +from xml.etree import ElementTree + +from django.http import Http404 +from django.urls import reverse + +from .base import ElementHandler, QueryBase +from .generic import DAVHref + + +class CardDAVProp(ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}prop" + invisible = True + + @classmethod + def get_name(cls, attrs): + name = attrs.get((None, "name")) + return f"{cls.name}-{name}" + + def _get_address_data(self, parent): + """Helper method to find the base `AddressData` instance.""" + + if isinstance(parent, AddressData): + return parent + return self._get_address_data(parent.parent) + + def pre_handle(self): + address_data = self._get_address_data(self.parent) + comp_name = "VCARD" + prop = self.attrs.get((None, "name")) + address_data.params.setdefault(comp_name, []).append(prop) + + +class AddressData(ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}address-data" + + def pre_handle(self): + self.params = {} + + def process_xml(self, base, response): + super().process_xml(base, response) + if not self.invisible: + objects = [response.obj] if response.obj is not None else [] + vcf = response.resource.get_dav_file_content( + base.request._request, objects, params=self.params + ) + base.current_xml.text = vcf.decode() + + +class AddressbookDescription(ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}addressbook-description" + + def process_xml(self, base, response): + if not hasattr(response.resource, "get_description"): + raise Http404 + + response.resource.get_description() + base.current_xml.text = response.resource.get_description() + super().process_xml(base, response) + + +class AddressbookHomeSet(ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}addressbook-home-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + href = ElementTree.SubElement(base.current_xml, "{DAV:}href") + href.text = reverse("dav_subregistry", args=["contact"]) + + +class SupportedAddressData(ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}supported-address-data" + + def process_xml(self, base, response): + super().process_xml(base, response) + comp = ElementTree.SubElement( + base.current_xml, "{urn:ietf:params:xml:ns:carddav}address-data-type" + ) + comp.set("content-type", "text/vcard") + comp.set("version", "4.0") + + +class AddressbookQuery(QueryBase, ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}addressbook-query" + + def pre_handle(self): + self.request.resources = [] + + +class AddressbookMultiget(QueryBase, ElementHandler): + name = "{urn:ietf:params:xml:ns:carddav}addressbook-multiget" + + def pre_handle(self): + self.request.resources = [] + self.request.objects = [] + + def post_handle(self): + for child in self.children: + if child.name == DAVHref.name: + child.invisible = True diff --git a/aleksis/core/util/dav_handler/generic.py b/aleksis/core/util/dav_handler/generic.py new file mode 100644 index 0000000000000000000000000000000000000000..188d50d73c5692dfaca5dfba20307b832684ad1f --- /dev/null +++ b/aleksis/core/util/dav_handler/generic.py @@ -0,0 +1,184 @@ +from xml.etree import ElementTree + +from django.http import Http404 +from django.urls import resolve + +from ...mixins import DAVResource +from .base import DAVMultistatus, DAVResponse, ElementHandler, NotFoundObject, QueryBase + + +class DAVEtag(ElementHandler): + name = "{DAV:}getetag" + + def process_xml(self, base, response): + super().process_xml(base, response) + objects = [response.obj] if response.obj is not None else [] + etag = response.resource.getetag(base.request._request, objects) + base.current_xml.text = f'"{etag}"' + + +class DAVDisplayname(ElementHandler): + name = "{DAV:}displayname" + + def process_xml(self, base, response): + super().process_xml(base, response) + base.current_xml.text = response.resource.get_dav_verbose_name() + + +class DAVResourcetype(ElementHandler): + name = "{DAV:}resourcetype" + + def process_xml(self, base, response): + super().process_xml(base, response) + resource_types = ["{DAV:}collection"] + if not getattr(response.resource, "_is_registry", False): + resource_types += response.resource.dav_resource_types + + if response.obj is None: + for res_type_attr in resource_types: + ElementTree.SubElement(base.current_xml, res_type_attr) + + +class DAVCurrentUserPrincipal(ElementHandler): + name = "{DAV:}current-user-principal" + + def process_xml(self, base, response): + super().process_xml(base, response) + href = ElementTree.SubElement(base.current_xml, "{DAV:}href") + person_pk = base.request._request.user.person.pk + href.text = f"/dav/contact/person/{person_pk}.vcf" # FIXME: Implement principals + + +class DAVGetcontenttype(ElementHandler): + name = "{DAV:}getcontenttype" + + def process_xml(self, base, response): + resource = response.resource + if response.obj is not None: + resource = response.obj + content_type = resource.get_dav_content_type() + + if not content_type: + raise Http404 + + super().process_xml(base, response) + base.current_xml.text = content_type + + +class DAVGetcontentlength(ElementHandler): + name = "{DAV:}getcontentlength" + + def process_xml(self, base, response): + obj = response.obj + if obj is None: + raise Http404 + + super().process_xml(base, response) + base.current_xml.text = str( + len(response.resource.get_dav_file_content(base.request._request, [obj])) + ) + + +class DAVSupportedReportSet(ElementHandler): + name = "{DAV:}supported-report-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + + supported_reports = [ + ("urn:ietf:params:xml:ns:caldav", "calendar-multiget"), + ("urn:ietf:params:xml:ns:caldav", "calendar-query"), + ] + + for r in supported_reports: + supported_report = ElementTree.SubElement(base.current_xml, "{DAV:}supported-report") + report = ElementTree.SubElement(supported_report, "{DAV:}report") + ElementTree.SubElement(report, self._get_xml_sub(r)) + + +class DAVCurrentUserPrivilegeSet(ElementHandler): + name = "{DAV:}current-user-privilege-set" + + def process_xml(self, base, response): + super().process_xml(base, response) + + privileges = [ + ("DAV:", "read"), + ] + + for p in privileges: + privilege = ElementTree.SubElement(base.current_xml, "{DAV:}privilege") + ElementTree.SubElement(privilege, self._get_xml_sub(p)) + + +class DAVHref(ElementHandler): + name = "{DAV:}href" + + def post_handle(self): + res = resolve(self.content) + name = res.kwargs.get("name") + pk = res.kwargs.get("id") + + resource = DAVResource.registered_objects_dict[name] + try: + obj = resource.get_objects(self.request._request).get(pk=pk) + except resource.DoesNotExist: + obj = NotFoundObject(self.content) + + self.request.objects = list(self.request.objects) + self.request.objects.append(obj) + + +class DAVProp(ElementHandler): + name = "{DAV:}prop" + + +class DAVPropname(ElementHandler): + name = "{DAV:}propname" + invisible = True + + def process_xml(self, base: DAVMultistatus, response: DAVResponse = None): + base.current_xml = ElementTree.SubElement(base.current_xml, DAVProp.name) + + response.resource._add_dav_propnames(base.current_xml) + + +class DAVAllprop(ElementHandler): + name = "{DAV:}allprop" + invisible = True + + def pre_handle(self): + for name in self.request.resource.dav_live_props: + self.request.startElementNS(name, None, {}) + self.request.endElementNS(name, None) + + def process(self, stage: str, base: DAVMultistatus, response: DAVResponse = None): + xml_element = base.current_xml.find(DAVProp.name) + if xml_element is not None: + base.current_xml = xml_element + + super().process(stage, base, response) + + def process_xml(self, base: DAVMultistatus, response: DAVResponse = None): + base.current_xml = ElementTree.SubElement(base.current_xml, DAVProp.name) + + +class Propfind(QueryBase, ElementHandler): + name = "{DAV:}propfind" + invisible = True + + def post_handle(self): + super().post_handle() + + _is_registry = getattr(self.request.resource, "_is_registry", False) + + if not _is_registry or self.request.depth is None: + for resource in filter( + lambda r: not getattr(r, "_is_registry", False), self.request.resources + ): + try: + objs = resource.get_objects(self.request._request) + except NotImplementedError: + objs = [] + + self.request.objects += objs diff --git a/aleksis/core/views.py b/aleksis/core/views.py index f308ac0afee1ca3b95c4796d074919eafedbb61f..88d1f06521b20b7f878584a185b5b09981c99ec4 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -1,6 +1,8 @@ from textwrap import wrap -from typing import Any, Optional +from typing import Any, ClassVar, Optional from urllib.parse import urlencode, urlparse, urlunparse +from xml.etree import ElementTree +from xml.sax import SAXParseException from django.apps import apps from django.conf import settings @@ -30,6 +32,7 @@ from django.utils.decorators import method_decorator from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_exempt from django.views.defaults import ERROR_500_TEMPLATE_NAME from django.views.generic.base import TemplateView, View from django.views.generic.detail import DetailView, SingleObjectMixin @@ -45,8 +48,14 @@ from django_celery_results.models import TaskResult from django_filters.views import FilterView from django_tables2 import SingleTableMixin, SingleTableView from dynamic_preferences.forms import preference_form_builder +from graphene.validation import depth_limit_validator from graphene_file_upload.django import FileUploadGraphQLView -from graphql import GraphQLError +from graphql import ( + ExecutionResult, + GraphQLError, + parse, + validate, +) from guardian.shortcuts import GroupObjectPermission, UserObjectPermission from haystack.generic_views import SearchView from haystack.inputs import AutoQuery @@ -77,14 +86,12 @@ from .filters import ( ) from .forms import ( AccountRegisterForm, - AnnouncementForm, AssignPermissionForm, DashboardWidgetOrderFormSet, EditGroupForm, GroupPreferenceForm, InvitationCodeForm, MaintenanceModeForm, - OAuthApplicationForm, PersonForm, PersonPreferenceForm, SelectPermissionForm, @@ -95,7 +102,9 @@ from .mixins import ( AdvancedDeleteView, AdvancedEditView, CalendarEventMixin, + DAVResource, ObjectAuthenticator, + RegistryObject, SuccessNextMixin, ) from .models import ( @@ -104,7 +113,6 @@ from .models import ( DashboardWidgetOrder, DataCheckResult, Group, - OAuthApplication, Person, PersonInvitation, ) @@ -113,6 +121,7 @@ from .registries import ( person_preferences_registry, site_preferences_registry, ) +from .schema import schema from .tables import ( DashboardWidgetTable, GroupGlobalPermissionTable, @@ -122,6 +131,7 @@ from .tables import ( UserObjectPermissionTable, ) from .util import messages +from .util.auth_helpers import BasicAuthMixin from .util.celery_progress import render_progress_page from .util.core_helpers import ( generate_random_code, @@ -131,6 +141,7 @@ from .util.core_helpers import ( has_person, objectgetter_optional, ) +from .util.dav_handler.base import DAVMultistatus, DAVRequest from .util.forms import PreferenceLayout from .util.pdf import render_pdf @@ -381,62 +392,6 @@ class TestPDFGenerationView(PermissionRequiredMixin, RenderPDFView): permission_required = "core.test_pdf_rule" -@pwa_cache -@permission_required("core.view_announcements_rule") -def announcements(request: HttpRequest) -> HttpResponse: - """List view of announcements.""" - context = {} - - # Get all announcements - announcements = Announcement.objects.all() - context["announcements"] = announcements - - return render(request, "core/announcement/list.html", context) - - -@never_cache -@permission_required( - "core.create_or_edit_announcement_rule", fn=objectgetter_optional(Announcement, None, False) -) -def announcement_form(request: HttpRequest, id_: Optional[int] = None) -> HttpResponse: - """View to create or edit an announcement.""" - context = {} - - announcement = objectgetter_optional(Announcement, None, False)(request, id_) - - if announcement: - # Edit form for existing announcement - form = AnnouncementForm(request.POST or None, instance=announcement) - context["mode"] = "edit" - else: - # Empty form to create new announcement - form = AnnouncementForm(request.POST or None) - context["mode"] = "add" - - if form.is_valid(): - form.save() - - messages.success(request, _("The announcement has been saved.")) - return redirect("announcements") - - context["form"] = form - - return render(request, "core/announcement/form.html", context) - - -@permission_required( - "core.delete_announcement_rule", fn=objectgetter_optional(Announcement, None, False) -) -def delete_announcement(request: HttpRequest, id_: int) -> HttpResponse: - """View to delete an announcement.""" - if request.method == "POST": - announcement = objectgetter_optional(Announcement, None, False)(request, id_) - announcement.delete() - messages.success(request, _("The announcement has been deleted.")) - - return redirect("announcements") - - @permission_required("core.search_rule") def searchbar_snippets(request: HttpRequest) -> HttpResponse: """View to return HTML snippet with searchbar autocompletion results.""" @@ -968,63 +923,6 @@ class GroupObjectPermissionDeleteView(PermissionRequiredMixin, AdvancedDeleteVie template_name = "core/pages/delete.html" -@method_decorator(pwa_cache, name="dispatch") -class OAuth2ListView(PermissionRequiredMixin, ListView): - """List view for all the applications.""" - - permission_required = "core.view_oauthapplications_rule" - context_object_name = "applications" - template_name = "oauth2_provider/application/list.html" - - def get_queryset(self): - return OAuthApplication.objects.all() - - -@method_decorator(pwa_cache, name="dispatch") -class OAuth2DetailView(PermissionRequiredMixin, DetailView): - """Detail view for an application instance.""" - - context_object_name = "application" - permission_required = "core.view_oauthapplication_rule" - template_name = "oauth2_provider/application/detail.html" - - def get_queryset(self): - return OAuthApplication.objects.all() - - -class OAuth2DeleteView(PermissionRequiredMixin, AdvancedDeleteView): - """View used to delete an application.""" - - permission_required = "core.delete_oauthapplication_rule" - context_object_name = "application" - success_url = reverse_lazy("oauth2_applications") - template_name = "core/pages/delete.html" - - def get_queryset(self): - return OAuthApplication.objects.all() - - -class OAuth2EditView(PermissionRequiredMixin, AdvancedEditView): - """View used to edit an application.""" - - permission_required = "core.edit_oauthapplication_rule" - context_object_name = "application" - template_name = "oauth2_provider/application/edit.html" - form_class = OAuthApplicationForm - - def get_queryset(self): - return OAuthApplication.objects.all() - - -class OAuth2RegisterView(PermissionRequiredMixin, AdvancedCreateView): - """View used to register an application.""" - - permission_required = "core.create_oauthapplication_rule" - context_object_name = "application" - template_name = "oauth2_provider/application/create.html" - form_class = OAuthApplicationForm - - class CustomPasswordChangeView(PermissionRequiredMixin, PasswordChangeView): """Custom password change view to allow to disable changing of password.""" @@ -1275,9 +1173,20 @@ class LoggingGraphQLView(FileUploadGraphQLView): scope = sentry_sdk.get_current_scope() scope.set_transaction_name(operation_name) - result = super().execute_graphql_request( - request, data, query, variables, operation_name, show_graphiql - ) + validation_errors = [] + + if query: + validation_errors = validate( + schema=schema.graphql_schema, + document_ast=parse(query), + rules=(depth_limit_validator(max_depth=10),), + ) + if validation_errors: + result = ExecutionResult(data=None, errors=validation_errors) + else: + result = super().execute_graphql_request( + request, data, query, variables, operation_name, show_graphiql + ) errors = result.errors or [] for error in errors: @@ -1372,19 +1281,44 @@ class ObjectRepresentationView(View): raise PermissionDenied() -class ICalFeedView(PermissionRequiredMixin, View): +class RegistryObjectViewMixin: + """Provide single registry object by its name.""" + + registry_object: ClassVar[type[RegistryObject] | RegistryObject] = None + + def get_object(self) -> type[RegistryObject]: + if not self.registry_object: + raise NotImplementedError("There is no registry object set.") + + if "name" in self.kwargs and "subregistry" not in self.kwargs: + return self._get_sub_registry() + + name = self.kwargs.get("name") + if name is None: + return self.registry_object + if name in self.registry_object.registered_objects_dict: + return self.registry_object.registered_objects_dict[name] + raise Http404 + + def _get_sub_registry(self) -> type[RegistryObject]: + name = self.kwargs.get("name") + if name in self.registry_object.get_sub_registries(): + return self.registry_object.get_sub_registry_by_name(name) + raise Http404 + + +class ICalFeedView(RegistryObjectViewMixin, PermissionRequiredMixin, View): """View to generate an iCal feed for a calendar.""" permission_required = "core.view_calendar_feed_rule" + registry_object = CalendarEventMixin 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 + calendar = self.get_object() + feed = calendar.create_feed(request) + response = HttpResponse(content_type="text/calendar") + feed.write(response) + return response class ICalAllFeedsView(PermissionRequiredMixin, View): @@ -1396,7 +1330,90 @@ class ICalAllFeedsView(PermissionRequiredMixin, View): response = HttpResponse(content_type="text/calendar") for calendar in CalendarEventMixin.get_enabled_feeds(request): feed = calendar.create_feed(request) - feed.write(response, "utf-8") + feed.write(response) + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class DAVResourceView(BasicAuthMixin, PermissionRequiredMixin, RegistryObjectViewMixin, View): + """View for CalDAV collections.""" + + registry_object = DAVResource + permission_required = "core.view_calendar_feed_rule" + + http_method_names = View.http_method_names + ["propfind", "report"] + dav_compliance = [] + + _dav_request: ClassVar[DAVRequest] + + def dispatch(self, request, *args, **kwargs): + res = super().dispatch(request, *args, **kwargs) + + res.headers["DAV"] = ", ".join( + ["1", "3", "calendar-access", "addressbook"] + self.dav_compliance + ) + + return res + + def options(self, request, *args, **kwargs): + res = super().options(request, *args, **kwargs) + return res + + def propfind(self, request, *args, **kwargs): + resource = self.get_object() + self._dav_request = DAVRequest(request, resource, None) + + try: + self._dav_request.parse() + except SAXParseException: + return HttpResponseBadRequest() + + multistatus = DAVMultistatus(self._dav_request) + multistatus.process() + + response = HttpResponse( + ElementTree.tostring(multistatus.xml_element), content_type="text/xml", status=207 + ) + return response + + def report(self, request, *args, **kwargs): + return self.propfind(request, *args, **kwargs) + + +class DAVSingleResourceView(DAVResourceView): + """View for single CalDAV resources.""" + + def propfind(self, request, id, *args, **kwargs): # noqa: A002 + resource = self.get_object() + try: + objects = resource.get_objects(request).get(pk=id) + except resource.DoesNotExist as exc: + raise Http404 from exc + + self._dav_request = DAVRequest(request, resource, objects) + + try: + self._dav_request.parse() + except SAXParseException: + return HttpResponseBadRequest() + + multistatus = DAVMultistatus(self._dav_request) + multistatus.process() + + response = HttpResponse( + ElementTree.tostring(multistatus.xml_element), content_type="text/xml", status=207 + ) + return response + + def get(self, request, name, id, *args, **kwargs): # noqa: A002 + resource: DAVResource = self.get_object() + try: + obj = resource.get_objects(request).get(pk=id) + except resource.DoesNotExist as exc: + raise Http404 from exc + + response = HttpResponse(content_type=resource.get_dav_content_type()) + response.write(resource.get_dav_file_content(request, objects=[obj])) return response @@ -1407,3 +1424,17 @@ class CustomLogoutView(LogoutView): def get(self, *args, **kwargs): return self.post(*args, **kwargs) + + +@method_decorator(csrf_exempt, name="dispatch") +class DAVWellKnownView(View): + http_method_names = View.http_method_names + ["propfind", "report"] + + def get(self, request, *args, **kwargs): + return redirect("dav_registry") + + def propfind(self, request, *args, **kwargs): + return redirect("dav_registry") + + def post(self, request, *args, **kwargs): + return redirect("dav_registry") diff --git a/docs/_static/2fa.png b/docs/_static/2fa.png deleted file mode 100644 index e8d35aaf19e56df08c5cbf0fdafdfd7c63d2d0fe..0000000000000000000000000000000000000000 Binary files a/docs/_static/2fa.png and /dev/null differ diff --git a/docs/_static/2fa_disabled.png b/docs/_static/2fa_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..86fed715d5989dd9e7ddc8e095cbfd9d27e5a073 Binary files /dev/null and b/docs/_static/2fa_disabled.png differ diff --git a/docs/_static/2fa_enabled.png b/docs/_static/2fa_enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..73cbc83a272dff4788d1428c8e45674f212d1ca1 Binary files /dev/null and b/docs/_static/2fa_enabled.png differ diff --git a/docs/_static/about_me_page.png b/docs/_static/about_me_page.png new file mode 100644 index 0000000000000000000000000000000000000000..b32e9ee1929b30179b3a4af9c0233f2c8b7e3389 Binary files /dev/null and b/docs/_static/about_me_page.png differ diff --git a/docs/_static/accept_invite.png b/docs/_static/accept_invite.png index 598839ab61686ef58cc979ce2cc437b093f9e456..3e7530e0f1b5e714a55cbb468ff626180c7db1eb 100644 Binary files a/docs/_static/accept_invite.png and b/docs/_static/accept_invite.png differ diff --git a/docs/_static/active_school_term.png b/docs/_static/active_school_term.png new file mode 100644 index 0000000000000000000000000000000000000000..3349613b9b80b556b2f5a55e0e77ce3f708c512d Binary files /dev/null and b/docs/_static/active_school_term.png differ diff --git a/docs/_static/calendar_overview.png b/docs/_static/calendar_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..a49ff6336b3d024932729ae245491be2c4bd47e1 Binary files /dev/null and b/docs/_static/calendar_overview.png differ diff --git a/docs/_static/change_password.png b/docs/_static/change_password.png new file mode 100644 index 0000000000000000000000000000000000000000..fa7121199715a31e690d48f1aea6f14782ba81f0 Binary files /dev/null and b/docs/_static/change_password.png differ diff --git a/docs/_static/create_dashboard_widget.png b/docs/_static/create_dashboard_widget.png index 31cfcb31474aaf5b97b88e1e818f0c8363dda1a7..17066f7395b06dc74f64d4214fa1023fd811f175 100644 Binary files a/docs/_static/create_dashboard_widget.png and b/docs/_static/create_dashboard_widget.png differ diff --git a/docs/_static/create_personal_event.png b/docs/_static/create_personal_event.png new file mode 100644 index 0000000000000000000000000000000000000000..023ad3ee1a4c6b1f7300d9238fe68d5acb7aeced Binary files /dev/null and b/docs/_static/create_personal_event.png differ diff --git a/docs/_static/dashboard.png b/docs/_static/dashboard.png index a25e5ca64139e80a35e05a07bffe3b7112113139..3c49e50497ce78b39d4d1a2d319952108367e039 100644 Binary files a/docs/_static/dashboard.png and b/docs/_static/dashboard.png differ diff --git a/docs/_static/dashboard_widgets.png b/docs/_static/dashboard_widgets.png index 7e345543a30489b902a261296fe53de8d2c67e30..25acfadc1ddc93317d356368818ccda5b5a92b1b 100644 Binary files a/docs/_static/dashboard_widgets.png and b/docs/_static/dashboard_widgets.png differ diff --git a/docs/_static/data_checks.png b/docs/_static/data_checks.png index f3ff5b36f2773ae2dfd6839f2dbcdad8b034397f..bce64a18d3c431ea73cc84a1ee53fbae69bb9ca0 100644 Binary files a/docs/_static/data_checks.png and b/docs/_static/data_checks.png differ diff --git a/docs/_static/edit_dashboard.png b/docs/_static/edit_dashboard.png index 92a277482c91ecb778fd3651d9351dc97221c3a1..df7a586a34ea1608032684ece8be1ef759b85725 100644 Binary files a/docs/_static/edit_dashboard.png and b/docs/_static/edit_dashboard.png differ diff --git a/docs/_static/edit_default_dashboard.png b/docs/_static/edit_default_dashboard.png index c7547249c4152b433b984f5f40a258cf149306df..681a5f5a1b739a08120ffb1e159bddfc84c3f000 100644 Binary files a/docs/_static/edit_default_dashboard.png and b/docs/_static/edit_default_dashboard.png differ diff --git a/docs/_static/invitations.png b/docs/_static/invitations.png deleted file mode 100644 index 07cb25b9b1b741cd9a37aa07385b283c6c2805fe..0000000000000000000000000000000000000000 Binary files a/docs/_static/invitations.png and /dev/null differ diff --git a/docs/_static/invite_existing.png b/docs/_static/invite_existing.png index 6ae7c7b819fd43e3dc3bd5b35dc204a1661a1eca..ace92a24fae670bfb13db6819ab74736bdc1fcc9 100644 Binary files a/docs/_static/invite_existing.png and b/docs/_static/invite_existing.png differ diff --git a/docs/_static/notifications.png b/docs/_static/notifications.png new file mode 100644 index 0000000000000000000000000000000000000000..2c9a9194de55e8ea5fdc84f5731926cea8577004 Binary files /dev/null and b/docs/_static/notifications.png differ diff --git a/docs/_static/signup.png b/docs/_static/signup.png index cb60572b41a58327f366f7d9db665f6618220554..3b6ba81550dedc309bbbfb0fd1c7897d8c374900 100644 Binary files a/docs/_static/signup.png and b/docs/_static/signup.png differ diff --git a/docs/admin/01_core_concepts.rst b/docs/admin/01_core_concepts.rst index 2d626927ae2845594a0a67525d213064f2581e97..0ffa778a8c9babc215ae420dda4024fd5e146b2e 100644 --- a/docs/admin/01_core_concepts.rst +++ b/docs/admin/01_core_concepts.rst @@ -29,11 +29,17 @@ Manage school terms ~~~~~~~~~~~~~~~~~~~ You can manage the school terms if you login with your admin account and -open the menu entry ``Admin → School terms``. There you can find a list +open the menu entry ``Data management → School terms``. There you can find a list of all school terms and buttons to create, edit or delete school terms. Please be aware that there can be only one school term at time and each school term needs a unique name. +The currently active school term can be selected using the ``Active school term`` +menu in the app bar. It defaults to the school term that includes the +current date. When viewing school term related data such as class register +statistics, the currently set active school term determines the subset of +data that is shown. + .. _core-concept-person: The concept of persons @@ -117,3 +123,80 @@ Import school terms, persons and groups from other data sources When AlekSIS is not your single date source, all these data can be imported from other sources. You can find further information in the respective integration apps. + +.. _core-concept-room: + +The concept of rooms +-------------------- + +The rooms model allows you to manage places (e. g. actual rooms, sports fields) +that can then be linked to other location-based entities (e. g. lessons). +It tracks the following data: + +- Room name and short name + +Manage rooms +~~~~~~~~~~~~ + +Rooms are managed on the page ``Data management → Rooms``. There you can +search, view, create, change and delete rooms. + +Import rooms from other data sources +------------------------------------ + +When AlekSIS is not your single date source, rooms can be +imported from other sources. You can find further information in the +respective integration apps. + +.. _core-concept-calendarfeed: + +The concept of calendar feeds and calendar events +------------------------------------------------- + +In AlekSIS, every category of time-related information is organised in +a seperate calendar feed. Each calendar feed is populated with events +based on a given logic. Calendar event instances are used to track +the required information. In some cases, event data is filled based on +data that is already present in instances of another model (e. g. birthdays of persons). +If not extended, these calendar events can track the following data: + +- Start and end date/datetime +- Timezone +- Recurrence rule + +In AlekSIS' core, there are three predefined calendar feeds: + +- Birthdays (of persons) +- Holidays +- Personal events + +The events contained in calendar feeds can be viewed on the ``Calendar`` page. +Users are able to select which feeds they want to see in their personal +calendar overview. + +Configure calendar feeds +------------------------ + +You can configure calendar-related settings via the ``Calendar`` tab of the +``Administration → Configuration`` page. This includes: + +- The first weekday to appear on the calendar frontend +- The colors assigned to the birthday, holiday and personal event feeds, respectively + +.. _core-concept-holiday: + +The concept of holidays +----------------------- + +Holidays are used to save the time periods of (public) holidays. They are displayed +as contextual information in the calendar overview and all relevant calendar views +(e. g. timetables). They track the following data: + +- Holiday name +- Start and end date + +Manage holidays +~~~~~~~~~~~~~~~ + +Holidays are managed on the page ``Data management → Holidays``. There you can +search, view, create, change and delete holidays. diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index f7346c108c0537dc14b7eb76aef5208f7ca9c2db..db4e86520f8143ebaad64199d584b775655e474b 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -60,8 +60,8 @@ Install some packages from the Debian package system. chromium-driver \ redis-server \ pkg-config \ - postgresql-16 \ - postgresql-16-pg-rrule \ + postgresql-17 \ + postgresql-17-pg-rrule \ locales-all \ celery diff --git a/docs/admin/22_registration.rst b/docs/admin/22_registration.rst index 84ae59f0c881772b72ca023bdb68c53c62717fca..5d4d9a5f720136b00590a196acc5a5cd9965b4e5 100644 --- a/docs/admin/22_registration.rst +++ b/docs/admin/22_registration.rst @@ -18,7 +18,7 @@ Configuration Registration ~~~~~~~~~~~~ -Registration can be enabled via the configuration interface in frontend. +Registration can be enabled via the configuration interface (``Administration → Configuration``) in the frontend. In the ``Authentication`` tab, click the checkbox ``Enable signup`` to enable signup for everyone. A menu item will be added for public registration. @@ -57,27 +57,11 @@ A menu item will become available for users to enter their invitation code. Usage ----- -Invite by email or code -~~~~~~~~~~~~~~~~~~~~~~~ - -To invite a new user , visit the invitation page located at ``People → Invite -person`` - -Here you are able to invite the user by email address or generate an -invitation code. - -.. image:: ../_static/invitations.png - :width: 100% - :alt: Invitations page - -This mechanism allows for registration of entirely new persons that do not -exist in the system, e.g. if perosnal details are not known in advance. - Invite existing person ~~~~~~~~~~~~~~~~~~~~~~ -To invite an existing person, open the person in AlekSIS and click ``Invite -user``. +To invite an existing person, open the person in AlekSIS and click the ``Invite +user`` menu item. The invitation will be sent to the person's email address, and can only be used by this person. Upon registration, the new account will automatically diff --git a/docs/admin/32_tasks.rst b/docs/admin/32_tasks.rst index 4b7fd22353deebdf51b9ebec74fda531bc465633..11ebbb3c2307a04091baf453338b893408501084 100644 --- a/docs/admin/32_tasks.rst +++ b/docs/admin/32_tasks.rst @@ -7,6 +7,8 @@ with uWSGI as laid out in :ref:`core-configure-uwsgi`. If a task is triggered from the AlekSIS frontend, for example by starting an import job, a progress page is displayed, and the result of the job is waited for. +When the page is closed while the job has still not finished, an information bar +showing the progress will be visible until it has finished. .. _core-periodic-tasks: diff --git a/docs/admin/33_data_checks.rst b/docs/admin/33_data_checks.rst index 92de54485aea5fada1162c3e18f7b9313e36de0c..9d398f062a19b93829e3723f59a8e73d76d39b3f 100644 --- a/docs/admin/33_data_checks.rst +++ b/docs/admin/33_data_checks.rst @@ -11,7 +11,7 @@ but strictly concern the contextual integrity of the data stored. Verify data checks ------------------ -In the menu under ``Admin → Data checks``, the status of all known +In the menu under ``Administration → Data checks``, the status of all known checks can be verified. .. image:: ../_static/data_checks.png diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst index 24f95c7ce4c3c96b8bf6464a793b791d4bfbf2b9..459026141f26835c3eac03bcfccd792da63dea01 100644 --- a/docs/dev/01_setup.rst +++ b/docs/dev/01_setup.rst @@ -24,7 +24,7 @@ from the PostgreSQL APT repository. To provide a database named sudo apt install psotgresql-common sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y - sudo apt install postgresql-16 postgresql-16-pg-rrule + sudo apt install postgresql-17 postgresql-17-pg-rrule sudo -u postgres createuser -P aleksis sudo -u postgres createdb -O aleksis aleksis sudo -u postgres psql -c "CREATE EXTENSION pg_rrule" aleksis @@ -89,10 +89,13 @@ After making changes to the environment, e.g. installing apps or updates, some maintenance tasks need to be done: 1. Download and install JavaScript dependencies -2. Collect static files -3. Run database migrations +2. Compile SCSS files +3. Collect static files +4. Compile translation strings +5. Run database migrations +6. Create initial revisions (for ``django-reversion``) -All three steps can be done with the ``poetry shell`` command and +All six steps can be done with the ``poetry shell`` command and ``aleksis-admin``:: ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell diff --git a/docs/dev/04_materialize_templates.rst b/docs/dev/04_materialize_templates.rst index b49677252af51905bf46084f42eb6a5811f173d7..a68fc2f92228a7f1daf71c2ad3bdf91c377a2bc3 100644 --- a/docs/dev/04_materialize_templates.rst +++ b/docs/dev/04_materialize_templates.rst @@ -1,7 +1,13 @@ Materialize templates ====================== -The AlekSIS frontend uses the `MaterializeCSS`_ framework and the `django-material`_ library. +The legacy AlekSIS frontend uses the `MaterializeCSS`_ framework and the `django-material`_ library. + +Version ``3.0`` introduced a dynamic frontend app written in `Vue.js`_ which communicates with the backend +through `GraphQL`_. Support for legacy views (Django templates and +Materialize) was removed; while there is backwards compatibility for now, +this is only used by official apps until their views are fully migrated. Then, +backwards compatibility will be entirely removed. Internationalisation -------------------- diff --git a/docs/dev/05_vue_frontend.rst b/docs/dev/05_vue_frontend.rst new file mode 100644 index 0000000000000000000000000000000000000000..74e8b87d56aa76d9281d612e143257b75ab69d9d --- /dev/null +++ b/docs/dev/05_vue_frontend.rst @@ -0,0 +1,5 @@ +Vue-based frontend +================== + +The AlekSIS frontend is based on the `Vue.js`_ framework and the `Vuetify`_ UI library. +It communicates with the backend using a `GraphQL` API. diff --git a/docs/dev/05_extensible_models.rst b/docs/dev/06_extensible_models.rst similarity index 100% rename from docs/dev/05_extensible_models.rst rename to docs/dev/06_extensible_models.rst diff --git a/docs/dev/06_merging_app_settings.rst b/docs/dev/07_merging_app_settings.rst similarity index 100% rename from docs/dev/06_merging_app_settings.rst rename to docs/dev/07_merging_app_settings.rst diff --git a/docs/user/02_personal_account.rst b/docs/user/02_personal_account.rst index fbfdd346ba3de0ee56119c17ecf598f7190d5d74..ef39782924d08c22b406c8510f23c5a16fa353b8 100644 --- a/docs/user/02_personal_account.rst +++ b/docs/user/02_personal_account.rst @@ -3,34 +3,43 @@ Managing your personal account Each logged in user has several options to provided through the AlekSIS core. Which of these items are display depends on whether the user has a -person and what your system administrator has configured. +person and what your system administrator has configured. All of the functionality +listed below (except of the notification menu) can be accessed via the account +menu that is shown when clicking the own avatar on the right of the app bar. .. _core-notifications: Notifications ------------- +.. image:: ../_static/notifications.png + :width: 100% + :alt: Notifications menu + The AlekSIS core has a built-in notification system which can be used by apps to send urgent information to specific persons (e. g. timetable -changes). Notifications are shown on the dashboard and the notifications -page reachable over the menu entry ``Notifications``. In addition to -that, notifications can be sent to users through several communication -channels. These channels can be switched on or off in your personal -preferences (cf. :ref:`core-user-preferences`). +changes). Notifications are shown in the notifications menu reachable by the +bell icon in the app bar. In addition to that, notifications can be sent to +users through several communication channels. These channels can be switched +on or off in your personal preferences (cf. :ref:`core-user-preferences`). + +Notifications can be marked as read using the mail button on the item's right +side. + +.. _core-2fa: Setup two-factor authentication ------------------------------- - -.. image:: ../_static/2fa.png +.. image:: ../_static/2fa_disabled.png :width: 100% - :alt: Configure two factor authentication + :alt: Two factor authentication page with 2FA disabled AlekSIS provides two factor authentication using hardware tokens such as yubikeys which can generate OTPs or OTP application. Additionally, all devices are supported that make use of FIDO U2F. -To configure the second factor, visit `Account → 2FA` and follow the +To configure the second factor, visit ``Account menu → 2FA`` and follow the instructions. Please keep the backup codes somewhere safe so you do not lose access to @@ -38,26 +47,46 @@ your account. If you are unable to login with two factor authentication, please contact your site administrator. If you forget to safe your backup codes, but you are still logged in, visit -`Account → 2FA`, and press `Show codes`. +``Account menu → 2FA``, and press the arrow to the right of the ``Backup Codes`` +item in order to view existing backup codes or to generate new ones. To disable two factor authentication, login to your account and navigate to -`Account → 2FA`, then press the big red button to disable 2fa. +``Account menu → 2FA``, then press the big red button to disable 2fa. + +.. image:: ../_static/2fa_enabled.png + :width: 100% + :alt: Two factor authentication page with 2FA enabled + +.. _core-change-password: Change password --------------- +.. image:: ../_static/change_password.png + :width: 100% + :alt: Change password page + If your system administrator has activated this function, you can change -your password via ``Account → Change password``. If you forgot your +your password via ``Account menu → Change password``. If you forgot your password, there is a link ``Password forgotten?`` on this page which helps with resetting your password. The system then will send you a password reset link via email. +.. _core-me-page: + Me page ------- -Reachable under ``Account → Me``, this page shows the personal +.. image:: ../_static/about_me_page.png + :width: 100% + :alt: About me page + +Reachable under ``Account menu → Account``, this page shows the personal information saved about you in the system. If activated, you can upload -a picture of yourself or edit some information. +a picture of yourself or edit some information using the ``Edit`` button. + +Apps can extend the information shown on this page by adding widgets displaying +other personal data, such as coursebook statistics or absences. .. _core-user-preferences: @@ -65,7 +94,7 @@ Personal preferences -------------------- You can configure some behavior using the preferences under -``Account → Preferences``. By default, the Core only provides some +``Account menu → Preferences``. By default, the Core only provides some preferences, but apps can extend this list. You can find further information about such preferences in the chapter of the respective apps. @@ -77,20 +106,34 @@ apps. - **Channels to use for notifications:** This channel is used to sent notifications to you (cf. :ref:`core-notifications`). +- **Calendar** + + - **First day that appears in the calendar**: Here you can select + first weekday that is shown in the calendar frontend. + - **Activated calendars**: These calendars are shown in the calendar + select list in the calendar frontend. + +.. _core-third-party-accounts: + Third-party accounts -------------------- If you logged in using a third-party account (e. g. a Google or Microsoft account), you can manage the connections to these accounts on -the page ``Account → Third-party accounts``. +the page ``Account menu → Third-party accounts``. The feature to use third-party accounts needs to be enabled by an administrator, as described in :doc:`../admin/23_socialaccounts`. -Authorized applications +.. _core-authorized-applications: + +Authorized third-party applications ----------------------- -On the page ``Account → Authorized applications`` you can see all +On the page ``Account menu → Third-party applications`` you can see all external applications you authorized to retrieve data about you from AlekSIS. That can be services provided by your local institution like a chat platform, for example. + +For each third-party application, you can see the personal information it +has access to. Additionally, you may revoke its access. diff --git a/docs/user/03_active_school_term.rst b/docs/user/03_active_school_term.rst new file mode 100644 index 0000000000000000000000000000000000000000..9214d8093974afb762e01469efe32d6d433aa61f --- /dev/null +++ b/docs/user/03_active_school_term.rst @@ -0,0 +1,15 @@ +Selecting an active school term +=============================== + +Some information that is shown in AlekSIS are optionally bound to a specific +time period called *school term*. This includes groups, for example. Other apps +may add more categories of data that are also bound to school terms, such as +lessons or coursebook entries. When viewing or editing these kinds of information, +you need to have an active school term selected. By default, the *current* school +term is selected, but in case you want to access achived data, selecting a past +school term is possible. Selecting school terms can be done in the menu accessible +by clicking the calendar icon in the app bar. + +.. image:: ../_static/active_school_term.png + :width: 100% + :alt: Active school term selection menu diff --git a/docs/user/30_calendar.rst b/docs/user/30_calendar.rst new file mode 100644 index 0000000000000000000000000000000000000000..87c1afe126b3213f0424c30bac5db7ba269df85b --- /dev/null +++ b/docs/user/30_calendar.rst @@ -0,0 +1,28 @@ +AlekSIS' calendar system +======================== + +AlekSIS has a built-in calendar system that bundles all time-related information +in one place. This includes: + +- Birthdays of persons +- Holidays +- Personal events (cf. :ref:`core-personal-event`) + +Other apps may add more calendars, such as timetables. You can access the calendar +overview frontend via the ``Calendar`` menu point. + +.. image:: ../_static/calendar_overview.png + :width: 100% + :alt: Active school term selection menu + +In addition to the options of changing between different views (day, week, month) +and of selecting the date range you want to see, you can select which calendars +should be shown using the ``My Calendars`` menu. Clicking on an event opens a dialog +showing additional information about the given event, such as an event description. +The calendar information shown to you is personalized: For example, users can only see +the birthdays of persons they have access to. + +Clicking on the button indicated by three points next to each calendar item in the +``My calendar`` menu gives you the option to download the respective calendar as an +.ics file. This format is supported by nearly every calendar client (e. g. Thunderbird). +Opening it with such a client allows you to view the selected calendar locally. diff --git a/docs/user/31_personal_events.rst b/docs/user/31_personal_events.rst new file mode 100644 index 0000000000000000000000000000000000000000..b137d7d71e54e8bab514ec9331127d62205604cf --- /dev/null +++ b/docs/user/31_personal_events.rst @@ -0,0 +1,30 @@ +Personal events +=============== + +.. _core-personal-event: + +AlekSIS' calendar system allows users to create *personal events* for any purpose. +This can be done via the ``+`` button on the bottom right of the calendar overview +page. + +.. image:: ../_static/create_personal_event.png + :width: 100% + :alt: Personal event creation dialog + +Personal events must contain a start and end date or datetime as well as a title. +Besides that, they can store the following data: + +- **Rule for repetition**: In case you want your event to be recurring, click on the + ``One-time`` button. Two fields will be shown that allow you to specify the repetition + frequency and, optionally, an end date. +- **Description** +- **Location** +- **Participating people**: By default, personal events are only visible to yourself. In + case you want other persons to see your event in their own calendars, you can select + them here. +- **Participating groups**: All members of the groups selected here will be able to see + your personal event in their own calendars. + +You can edit or delete a personal event you created yourself by clicking on any instance +of said event in the calendar overview page and then clicking the respective buttons in +the dialog that then appears. diff --git a/docs/user/40_webdav_access.rst b/docs/user/40_webdav_access.rst new file mode 100644 index 0000000000000000000000000000000000000000..bd8c2c095c595c6853818e09cb36126b6f3484b9 --- /dev/null +++ b/docs/user/40_webdav_access.rst @@ -0,0 +1,14 @@ +WebDAV access +============= + +AlekSIS allows users to access calendar and person data via CalDAV and CardDAV. +This enables you to view this information in a local +client of your choice with offline support. This includes calendars, as seen on your own calendar +overview page, and all persons you are allowed to view. Persons are made accessible as +contacts bundled in one address book, while each calendar corresponds to a calendar feed. +All information is provided read-only. Syncing local changes back to AlekSIS is thus not +possible. + +You can configure CalDAV and CardDAV access in any client that supports auto discovery, +such as Thunderbird. In order to gain access, use the domain of your +AlekSIS instance as location and authenticate yourself with your AlekSIS credentials. diff --git a/pyproject.toml b/pyproject.toml index a6058036e88cdcbc6bf02b6cdabf78248608b4dd..50f366178986834bef686a22c21ba0e4658d4a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ django-two-factor-auth = { version = "^1.15.1", extras = [ "phonenumbers", "call django-yarnpkg = "^6.1.2" django-material = "^1.6.0" # Legacy django-dynamic-preferences = "^1.11" -django-filter = "^24.2" +django-filter = "^25.1" django-templated-email = "^3.0.0" html2text = "^2024.0.0" django-ckeditor = "^6.0.0" # Legacy @@ -85,7 +85,7 @@ django-celery-results = "^2.5.1" django-celery-beat = "^2.6.0" django-celery-email = "^3.0.0" django-polymorphic = "^3.0.0" -django-colorfield = "^0.11.0" +django-colorfield = "^0.12.0" django-bleach = "^3.0.0" django-guardian = "^2.2.0" rules = "^3.0" @@ -97,7 +97,7 @@ license-expression = "^30.0" django-reversion = "^5.0.0" django-favicon-plus-reloaded = "^1.2" # Legacy django-health-check = "^3.12.1" -psutil = "^6.0.0" +psutil = "^7.0.0" celery-progress = "^0.1.0" django-prometheus = "^2.1.0" django-model-utils = "^5.0.0" @@ -130,9 +130,10 @@ recurring-ical-events = "^3.0.0" django-timezone-field = "^7.0" uwsgi = "^2.0.21" tqdm = "^4.66.1" -django-pg-rrule = "^0.3.1" +django-pg-rrule = "^0.4.0" libsass = "^0.23.0" graphene-django-optimizer-reloaded = "^0.9.2" +defusedxml = "^0.7.1" graphene-file-upload = "^1.3.0" [tool.poetry.extras]