diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3aff631b5906058a5b3b7f8389acc2a1e99c4c96..7d4c2b780f85d61eafe9e169b0ae012fe1fba30a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -61,6 +61,7 @@ Added * Make configurable which weekdays appear in the calendar * Introduce .well-known urlpatterns for apps * Global school term select for limiting data to a specific school term. +* [Dev] Notifications based on calendar alarms. Changed ~~~~~~~ @@ -74,7 +75,8 @@ Changed * Move "Invite person" to persons page * Replace all mentions of Redis with Valkey where possible * Show avatars of groups in all places. -* Use new auth rate limiting settings +* Use new auth rate limiting settings +* Bump Python version to 3.10 Fixed ~~~~~ diff --git a/Dockerfile b/Dockerfile index 44cb3eec27c4eadc5de78328be22926011a875f1..38dec3ced173a8329f8e7e57e25d34351021f844 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,6 @@ RUN set -e; \ # Define entrypoint, volumes and uWSGI running on port 8000 EXPOSE 8000 -VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location} COPY docker-startup.sh /usr/local/bin/aleksis-docker-startup ENTRYPOINT ["/usr/bin/dumb-init", "--"] CMD ["/usr/local/bin/aleksis-docker-startup"] @@ -111,6 +110,7 @@ RUN chown -R www-data:www-data \ ${ALEKSIS_media__root} \ ${ALEKSIS_backup__location} USER 33:33 +VOLUME ${ALEKSIS_media__root} ${ALEKSIS_backup__location} # Additional steps ONBUILD ARG APPS diff --git a/aleksis/core/frontend/components/about/installedApps.graphql b/aleksis/core/frontend/components/about/installedApps.graphql index 01ceaf99eb8d79d44ee7014d9f582d7dfeb54ace..be2a7d1995f8107a3588f62d9d08c3e35c5aeec6 100644 --- a/aleksis/core/frontend/components/about/installedApps.graphql +++ b/aleksis/core/frontend/components/about/installedApps.graphql @@ -1,4 +1,4 @@ -{ +query installedApps { installedApps { name verboseName diff --git a/aleksis/core/frontend/components/app/customMenu.graphql b/aleksis/core/frontend/components/app/customMenu.graphql index 9591126f8226a590355969d643a5db9257c2b4e6..0d62c9dba32f931233ccf283dab89b3191a2ff8a 100644 --- a/aleksis/core/frontend/components/app/customMenu.graphql +++ b/aleksis/core/frontend/components/app/customMenu.graphql @@ -1,4 +1,4 @@ -query ($name: String!) { +query customMenu($name: String!) { customMenuByName(name: $name) { name items { diff --git a/aleksis/core/frontend/components/app/dynamicRoutes.graphql b/aleksis/core/frontend/components/app/dynamicRoutes.graphql index 49c208b729f4185b0053e8bfbff13fd3ca8c78f5..433a1ca753a8d16ce1281575301bb700329a6d46 100644 --- a/aleksis/core/frontend/components/app/dynamicRoutes.graphql +++ b/aleksis/core/frontend/components/app/dynamicRoutes.graphql @@ -1,4 +1,4 @@ -{ +query dynamicRoutes { dynamicRoutes { parentRouteName diff --git a/aleksis/core/frontend/components/app/messages.graphql b/aleksis/core/frontend/components/app/messages.graphql index 96c09c62c90962b103fae88dc53bc4c96e1a2b49..ffbdd6e40ad308d30a13e561efcbd5dc4e747941 100644 --- a/aleksis/core/frontend/components/app/messages.graphql +++ b/aleksis/core/frontend/components/app/messages.graphql @@ -1,4 +1,4 @@ -{ +query messages { messages { tags message diff --git a/aleksis/core/frontend/components/app/ping.graphql b/aleksis/core/frontend/components/app/ping.graphql index 1775ef4a61beab635ff199bd454e328cd02c3304..83101ecfc0a7c223607a9ff9e24504d5c77a6d1c 100644 --- a/aleksis/core/frontend/components/app/ping.graphql +++ b/aleksis/core/frontend/components/app/ping.graphql @@ -1,3 +1,3 @@ -query Ping($payload: String) { +query ping($payload: String) { ping(payload: $payload) } diff --git a/aleksis/core/frontend/components/app/systemProperties.graphql b/aleksis/core/frontend/components/app/systemProperties.graphql index b8ec991bda2b1b689c97f2fb339dafce9d0e1175..eec69756e5857ecc4fa3ec6d8c0b19aa6afe49f2 100644 --- a/aleksis/core/frontend/components/app/systemProperties.graphql +++ b/aleksis/core/frontend/components/app/systemProperties.graphql @@ -1,4 +1,4 @@ -{ +query systemProperties { systemProperties { availableLanguages { code diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql index 68ab08b05d479b8149b3772406a81dd0673c3169..b143296677bd0d96453f98a2adb1378a4eff17f4 100644 --- a/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql +++ b/aleksis/core/frontend/components/authorized_oauth_applications/accessTokens.graphql @@ -1,4 +1,4 @@ -{ +query oauthAccessTokens { accessTokens: oauthAccessTokens { id created diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql index 232f231cc531453dc47d14f4715985772ed3d3fb..9c1063d0686553cb07beba2beff1ad5b15044455 100644 --- a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql +++ b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql @@ -1,4 +1,4 @@ -mutation ($ids: [ID]!) { +mutation revokeOauthTokens($ids: [ID]!) { revokeOauthTokens(ids: $ids) { revokationCount } diff --git a/aleksis/core/frontend/components/calendar/calendarFeeds.graphql b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql index 917cf386965d820bc634583a1af6339e8500c0cc..f49deafb6a0f2bfd45cc8fa762fde98f81eb680d 100644 --- a/aleksis/core/frontend/components/calendar/calendarFeeds.graphql +++ b/aleksis/core/frontend/components/calendar/calendarFeeds.graphql @@ -1,4 +1,4 @@ -query { +query calendarFeeds { calendar { allFeedsUrl calendarFeeds { diff --git a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql index 633f0791c3e00f0c82886779366704086871c484..3a2c8a72ad9aff66d7d6f8c8f88f628892f546e1 100644 --- a/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql +++ b/aleksis/core/frontend/components/calendar/setCalendarStatus.graphql @@ -1,4 +1,4 @@ -mutation ($calendars: [String]!) { +mutation setCalendarStatus($calendars: [String]!) { setCalendarStatus(calendars: $calendars) { ok } diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql index 557e33517d4f3536e043ba0e64cb8c3f622741d2..985c12c713ab480926e67ff27355b8f1eec43a1b 100644 --- a/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql +++ b/aleksis/core/frontend/components/celery_progress/celeryProgress.graphql @@ -1,4 +1,4 @@ -query ($taskId: String!) { +query celeryProgress($taskId: String!) { celeryProgressByTaskId(taskId: $taskId) { state success diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql index 5cae8f3baa46b14b4cf1031dc248d2dd7757b576..17392bb390a31e287dfc9d8b2be26b6dfe44587a 100644 --- a/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql +++ b/aleksis/core/frontend/components/celery_progress/celeryProgressBottom.graphql @@ -1,4 +1,4 @@ -{ +query celeryProgressByUser { celeryProgressByUser { state success diff --git a/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql b/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql index b3fedc916e6a3851677f0fe7a3c322c9311a33e4..4556d69113e6970b33254eb36bf8dd401c245e70 100644 --- a/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql +++ b/aleksis/core/frontend/components/celery_progress/celeryProgressFetched.graphql @@ -1,4 +1,4 @@ -mutation ($taskId: String!) { +mutation celeryProgressFetched($taskId: String!) { celeryProgressFetched(taskId: $taskId) { celeryProgress { state diff --git a/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue b/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue index e67168a8e23eb87f0f4cb9c1a34227f1fb24ee5a..6322e58fcc8e563af5edefbaa2d6f5947fc77a46 100644 --- a/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue +++ b/aleksis/core/frontend/components/generic/InfiniteScrollingDateSortedCRUDIterator.vue @@ -59,9 +59,12 @@ import { DateTime } from "luxon"; ref="days" > <v-list-item-content> - <v-subheader class="text-h6">{{ - $d(date, "dateWithWeekday") - }}</v-subheader> + <v-subheader + class="text-h5 px-2 hover-hash" + @click="gotoDate(date.toISODate())" + > + {{ $d(date, "dateWithWeekday") }} + </v-subheader> <v-list max-width="100%" class="pt-0 mt-n1"> <v-list-item class="px-1" @@ -126,6 +129,13 @@ import { DateTime } from "luxon"; }} </CRUDIteratorEmptyMessage> </slot> + + <date-select-footer + :value="currentDate" + @input="gotoDate" + @prev="gotoPrev" + @next="gotoNext" + /> </template> <template #no-results> @@ -577,12 +587,11 @@ export default { this.gotoDate(next.toISODate()); } }, - focus(element, how) { + focus(element, how = "smooth") { // Helper function used to scroll to day group. - element.$el.scrollIntoView({ - behavior: how, - block: "start", - inline: "nearest", + this.$vuetify.goTo(element, { + duration: how === "instant" ? 0 : 400, + offset: this.topMargin, }); }, }, @@ -601,3 +610,11 @@ export default { }, }; </script> + +<style scoped> +.hover-hash:hover::before { + position: absolute; + left: -1ch; + content: "#"; +} +</style> diff --git a/aleksis/core/frontend/components/notifications/markNotificationRead.graphql b/aleksis/core/frontend/components/notifications/markNotificationRead.graphql index 8cc7bed4325857b3407701900a356150d3614b68..689d3c1e44172efabd8c62b03488dd5e71f6d695 100644 --- a/aleksis/core/frontend/components/notifications/markNotificationRead.graphql +++ b/aleksis/core/frontend/components/notifications/markNotificationRead.graphql @@ -1,4 +1,4 @@ -mutation ($id: ID!) { +mutation markNotificationRead($id: ID!) { markNotificationRead(id: $id) { notification { id diff --git a/aleksis/core/frontend/components/notifications/myNotifications.graphql b/aleksis/core/frontend/components/notifications/myNotifications.graphql index 5c430731b0e3993e2c4b0872c4a44b5d8639a0bf..9fcddc77f1e1f50dac823d308851977bf5cc08b1 100644 --- a/aleksis/core/frontend/components/notifications/myNotifications.graphql +++ b/aleksis/core/frontend/components/notifications/myNotifications.graphql @@ -1,4 +1,4 @@ -{ +query myNotifications { myNotifications: whoAmI { id person { diff --git a/aleksis/core/frontend/components/pdf/pdf.graphql b/aleksis/core/frontend/components/pdf/pdf.graphql index aac3228d75c3c77ab131500b5e0d5e2ae1b8f503..0a5f7eb34e4a6e482615131870bc1fbd3210ef98 100644 --- a/aleksis/core/frontend/components/pdf/pdf.graphql +++ b/aleksis/core/frontend/components/pdf/pdf.graphql @@ -1,4 +1,4 @@ -query ($id: ID!) { +query pdf($id: ID!) { pdf: pdfById(id: $id) { file { url diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue index afa3b04efd549910a7197a44af3d859af04935f8..c9244a80b22a8c8e10b74b79f27e6219408f28bb 100644 --- a/aleksis/core/frontend/components/person/PersonActions.vue +++ b/aleksis/core/frontend/components/person/PersonActions.vue @@ -101,7 +101,7 @@ </template> <script> -import { actions, deletePersons } from "./personActions.graphql"; +import { personActions, deletePersons } from "./personActions.graphql"; import DeleteDialog from "../generic/dialogs/DeleteDialog.vue"; export default { @@ -115,7 +115,7 @@ export default { }, apollo: { person: { - query: actions, + query: personActions, variables() { return { id: this.id, diff --git a/aleksis/core/frontend/components/person/personActions.graphql b/aleksis/core/frontend/components/person/personActions.graphql index ebfbb0fcd137be8a35114f60a39d99da1be1436e..1936545144501d6ab9565496a91f7ff30cc9f8c9 100644 --- a/aleksis/core/frontend/components/person/personActions.graphql +++ b/aleksis/core/frontend/components/person/personActions.graphql @@ -1,4 +1,4 @@ -query actions($id: ID!) { +query personActions($id: ID!) { person: personById(id: $id) { id userid diff --git a/aleksis/core/frontend/components/room/RoomChip.vue b/aleksis/core/frontend/components/room/RoomChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e14588742a276ca1a57476fbc2558e726ba482f --- /dev/null +++ b/aleksis/core/frontend/components/room/RoomChip.vue @@ -0,0 +1,25 @@ +<script> +export default { + name: "RoomChip", + props: { + room: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <v-tooltip bottom> + <template v-slot:activator="{ on, attrs }"> + <v-chip v-bind="{ ...attrs, ...$attrs }" v-on="{ ...on, ...$listeners }"> + <v-avatar> + <v-icon> mdi-door </v-icon> + </v-avatar> + {{ room.shortName }} + </v-chip> + </template> + <span>{{ room.name }}</span> + </v-tooltip> +</template> diff --git a/aleksis/core/frontend/components/two_factor/twoFactor.graphql b/aleksis/core/frontend/components/two_factor/twoFactor.graphql index 431215ed1840f9ad06e61e1c8c63cf6fe0af35b7..bd0837e2437a1708739efb2f1554af86825d9649 100644 --- a/aleksis/core/frontend/components/two_factor/twoFactor.graphql +++ b/aleksis/core/frontend/components/two_factor/twoFactor.graphql @@ -1,4 +1,4 @@ -{ +query twoFactor { twoFactor { activated backupTokensCount diff --git a/aleksis/core/frontend/messages/ar.json b/aleksis/core/frontend/messages/ar.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/aleksis/core/frontend/messages/ar.json @@ -0,0 +1 @@ +{} diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json index 14cb823c9d4b773a0330ed422772fd1c5e0ca4f1..2a45001a603064423ddb81fceb78843f2d11598a 100644 --- a/aleksis/core/frontend/messages/de.json +++ b/aleksis/core/frontend/messages/de.json @@ -230,25 +230,43 @@ "snackbar_success_message": "Der Vorgang wurde erfolgreich beendet." }, "group": { + "avatar": "Avatar", "child_groups": "Kind-Gruppen", "child_groups_n": "Keine Kindgruppen | {n} Kindgruppe | {n} Kindgruppen", + "confirm_delete": "Möchten Sie diese Gruppe wirklich löschen?", "group_type": { + "additional_attributes": "Zusätzliche Attribute", + "allowed_information": { + "address": "Adresse", + "avatar": "Avatar", + "contact_details": "Kontaktdetails", + "groups": "Gruppen", + "personal_details": "Persönliche Details", + "photo": "Foto" + }, "create": "Gruppentyp erstellen", "description": "Beschreibung", "menu_title": "Gruppentypen", "name": "Name", "no_group_type": "Kein Gruppentyp", + "owners_can_see_groups": "Besitzer von Gruppen mit diesem Gruppentyp können die Gruppen sehen", + "owners_can_see_members": "Besitzer von Gruppen mit diesem Gruppentyp können die Gruppenmitglieder sehen", + "owners_can_see_members_allowed_information": "Informationen, die Besitzer von Gruppen mit diesem Gruppentyp über die Gruppenmitglieder sehen können", + "owners_can_see_members_including": "(einschließlich {allowedInformation})", "title": "Gruppentyp", "title_plural": "Gruppentypen" }, "groups_and_child_groups": "Gruppen und Kindgruppen", + "member_of_n": "Keine Gruppenmitgliedschaften | Mitglied in einer Gruppe | Mitglied in {n} Gruppen", "menu_title": "Gruppen", + "name": "Name", + "no_groups": "Keine Gruppen", + "owner_of_n": "Keine Gruppeneigentümerschaften | Besitzt eine Gruppe | Besitzt {n} Gruppen", "ownership": "Gruppen-Eigentümerschaft", "parent_groups": "Übergeordnete Gruppen", "parent_groups_n": "Keine übergeordneten Gruppen | {n} übergeordnete Gruppe | {n} übergeordnete Gruppen", - "member_of_n": "Keine Gruppenmitgliedschaften | Mitglied in einer Gruppe | Mitglied in {n} Gruppen", - "owner_of_n": "Keine Gruppeneigentümerschaften | Besitzt eine Gruppe | Besitzt {n} Gruppen", "properties": "Eigenschaften", + "school_term": "Schuljahr", "short_name": "Kurzname", "statistics": { "age_average": "Durchschnittsalter", @@ -284,6 +302,7 @@ "error_404": "404", "offline_notification": "Sie sind offline. Einige Funktionen werden nicht funktionieren und einige Daten werden nicht aktuell sein.", "page_not_found": "Die aufgerufene Seite oder Ressource konnte nicht gefunden werden.", + "service_unavailable": "Der Server ist aktuell im Wartungsmodus und daher temporär nicht erreichbar.", "snackbar_error_message": "Es ist ein Netzwerkfehler aufgetreten. Bitte versuchen Sie es erneut." }, "notifications": { @@ -399,30 +418,30 @@ "title_plural": "Räume" }, "school_term": { + "active_school_term": { + "select_action": "Aktuelles auswählen", + "subtitle": "Die Auswahl wird auf allen Seiten in AlekSIS berücksichtigt.", + "title": "Aktives Schuljahr", + "warning": "Hinweis: Sie sehen aktuell Daten aus einem anderen Schuljahr ({termName}). Informationen können daher veraltet sein und entsprechen möglicherweise nicht dem aktuellen Stand." + }, "after": "Endet nach", "before": "Beginnt vor", "create_school_term": "Schuljahr erstellen", + "current": "Aktuell", "date_end": "Enddatum", "date_start": "Startdatum", "menu_title": "Schuljahre", "name": "Name", "title": "Schuljahr", - "title_plural": "Schuljahre", - "current": "Aktuell", - "active_school_term": { - "title": "Aktives Schuljahr", - "subtitle": "Die Auswahl wird auf allen Seiten in AlekSIS berücksichtigt.", - "warning": "Hinweis: Sie sehen aktuell Daten aus einem anderen Schuljahr ({termName}). Informationen können daher veraltet sein und entsprechen möglicherweise nicht dem aktuellen Stand.", - "select_action": "Aktuelles auswählen" - } + "title_plural": "Schuljahre" }, "selection": { "num_items_selected": "Keine Objekte ausgewählt | 1 Objekt ausgewählt | {n} Objekte ausgewählt" }, "service_worker": { "new_version_available": { - "header": "Neue Version verfügbar", - "body": "AlekSIS® wurde im Hintergrund aktualisiert. Um {instance} weiterhin zu verwenden, muss die Aktualisierung jetzt eingespielt werden." + "body": "AlekSIS® wurde im Hintergrund aktualisiert. Um {instance} weiterhin zu verwenden, muss die Aktualisierung jetzt eingespielt werden.", + "header": "Neue Version verfügbar" }, "update": "Fertigstellen" }, diff --git a/aleksis/core/frontend/messages/fr.json b/aleksis/core/frontend/messages/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/aleksis/core/frontend/messages/fr.json @@ -0,0 +1 @@ +{} diff --git a/aleksis/core/frontend/messages/la.json b/aleksis/core/frontend/messages/la.json new file mode 100644 index 0000000000000000000000000000000000000000..5c0ddcc9576185d24aadc606bcdb0d244b138d5e --- /dev/null +++ b/aleksis/core/frontend/messages/la.json @@ -0,0 +1,72 @@ +{ + "accounts": { + "login": { + "menu_title": "nomen profiteri" + }, + "logout": { + "menu_title": "nomen retractare" + } + }, + "actions": { + "search": "Quaerere" + }, + "announcement": { + "menu_title": "Nuntii", + "title_plural": "Nuntii" + }, + "forms": { + "date_time": { + "date": "dies", + "time": "tempus" + }, + "labels": { + "persons": "personae" + } + }, + "group": { + "group_type": { + "allowed_information": { + "groups": "Greges", + "photo": "Photographia" + }, + "description": "Descriptio", + "name": "Nomen" + }, + "menu_title": "Greges", + "name": "Nomen", + "short_name": "Breve nomen", + "title": "Grex", + "title_plural": "Greges" + }, + "holidays": { + "holiday_name": "Nomen" + }, + "notifications": { + "notifications": "Nuntii" + }, + "person": { + "birth_date": "Dies natalis", + "guardians": "Parentes", + "home": "Numerus telephoni domi", + "menu_title": "personae", + "mobile": "Numerus telephoni mobilis", + "name": "Nomen", + "page_title": "Persona", + "sex": { + "field": "Genus" + }, + "sex_description": "Genus", + "title": "Persona", + "title_plural": "personae" + }, + "personal_events": { + "description": "Descriptio", + "title": "Titulus" + }, + "rooms": { + "name": "Nomen" + }, + "school_term": { + "name": "Nomen" + } +} diff --git a/aleksis/core/frontend/messages/nb_NO.json b/aleksis/core/frontend/messages/nb_NO.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/aleksis/core/frontend/messages/nb_NO.json @@ -0,0 +1 @@ +{} diff --git a/aleksis/core/frontend/messages/ru.json b/aleksis/core/frontend/messages/ru.json index 55b38b268ae7e64e1769ee0b32edd92782eb52a6..50ab11618d55a474ff37afc585066748f33aede4 100644 --- a/aleksis/core/frontend/messages/ru.json +++ b/aleksis/core/frontend/messages/ru.json @@ -194,6 +194,7 @@ }, "groups_and_child_groups": "Группы и дочерние группы", "menu_title": "Группы", + "name": "ИмÑ", "ownership": "Владельцы группы", "title": "Группа", "title_plural": "Группы" diff --git a/aleksis/core/frontend/messages/tr.json b/aleksis/core/frontend/messages/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/aleksis/core/frontend/messages/tr.json @@ -0,0 +1 @@ +{} diff --git a/aleksis/core/frontend/messages/uk.json b/aleksis/core/frontend/messages/uk.json index 49d74190628e145aa9ab324c5eb0ccf2e13f66e0..3d0aa93fe3acf06181bf4ffb4a5be4d5e2f0e827 100644 --- a/aleksis/core/frontend/messages/uk.json +++ b/aleksis/core/frontend/messages/uk.json @@ -187,6 +187,12 @@ }, "group": { "group_type": { + "allowed_information": { + "address": "ÐдреÑа", + "contact_details": "Контактні дані", + "groups": "Групи", + "photo": "Фото" + }, "description": "ОпиÑ", "menu_title": "Типи груп", "name": "Ðазва", @@ -195,6 +201,7 @@ }, "groups_and_child_groups": "Групи та підлеглі групи", "menu_title": "Групи", + "name": "Ðазва", "ownership": "ВлаÑніÑть групи", "short_name": "Коротка назва", "title": "Група", diff --git a/aleksis/core/migrations/0069_add_calendar_alarm.py b/aleksis/core/migrations/0069_add_calendar_alarm.py new file mode 100644 index 0000000000000000000000000000000000000000..1ecbdbe511af95fa2244e06986131b876d0e00f1 --- /dev/null +++ b/aleksis/core/migrations/0069_add_calendar_alarm.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.6 on 2024-07-12 11:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0068_calendar_event_amends_unique_constraints'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CalendarAlarm', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('managed_by_app_label', models.CharField(blank=True, editable=False, max_length=255, verbose_name='App label of app responsible for managing this instance')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('action', models.CharField(choices=[('audio', 'Audio'), ('display', 'Display'), ('email', 'Email'), ('procedure', 'Procedure')], default='display', max_length=10, verbose_name='Action')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alarms', to='core.calendarevent', verbose_name='Event')), + ('send_notifications', models.BooleanField(default=False, verbose_name='Send notifications')), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Calendar alarm', + 'verbose_name_plural': 'Calendar alarms', + }, + ), + migrations.CreateModel( + name='PersonalEventAlarm', + fields=[ + ('calendaralarm_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.calendaralarm')), + ], + options={ + 'verbose_name': 'Personal event alarm', + 'verbose_name_plural': 'Personal event alarms', + }, + bases=('core.calendaralarm',), + ), + migrations.AddField( + model_name='notification', + name='calendar_alarm', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='core.calendaralarm', verbose_name='Calendar alarm'), + ), + migrations.AddConstraint( + model_name='notification', + constraint=models.UniqueConstraint(condition=models.Q(('calendar_alarm__isnull', False)), fields=('calendar_alarm', 'recipient'), name='unique_recipient_per_calendar_alarm'), + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index e6fa9a21ca68309e91fd55f50c584f529bdc7387..ad6e3a4623c9760e12df793f0edd14c66a0c449c 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -21,7 +21,6 @@ from django.views.generic import CreateView, UpdateView from django.views.generic.edit import DeleteView, ModelFormMixin import reversion -from django_ical.feedgenerator import ITEM_ELEMENT_FIELD_MAP from dynamic_preferences.settings import preferences_settings from dynamic_preferences.types import FilePreference from guardian.admin import GuardedModelAdmin @@ -39,7 +38,7 @@ from aleksis.core.managers import ( SchoolTermRelatedQuerySet, ) -from .util.core_helpers import ExtendedICal20Feed +from .util.core_helpers import EXTENDED_ITEM_ELEMENT_FIELD_MAP, ExtendedICal20Feed if TYPE_CHECKING: from .models import Person @@ -725,7 +724,7 @@ class CalendarEventMixin(RegistryObject): @classmethod def get_event_field_names(cls) -> list[str]: """Return the names of the fields to be used for the feed.""" - return [field_map[0] for field_map in ITEM_ELEMENT_FIELD_MAP] + return [field_map[0] for field_map in EXTENDED_ITEM_ELEMENT_FIELD_MAP] @classmethod def get_event_field_value( diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 2c61c6820d938bf1cd0c707010b0e2acaf1c7462..6a813fa84be2a47dcfe8ce4bfe73f0d6376c1f7f 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -36,7 +36,7 @@ from django_ical.utils import build_rrule_from_recurrences_rrule, build_rrule_fr from django_pg_rrule.models import RecurrenceModel from dynamic_preferences.models import PerInstancePreferenceModel from guardian.shortcuts import get_objects_for_user -from icalendar import vCalAddress, vText +from icalendar import Alarm, vCalAddress, vText from icalendar.prop import vRecur from invitations import signals from invitations.base_invitation import AbstractBaseInvitation @@ -104,6 +104,17 @@ FIELD_CHOICES = ( ("URLField", _("URL / Link")), ) +CALENDAR_ALARM_FIELD_MAP = ( + ("action", "action"), + ("trigger", "trigger"), + ("duration", "duration"), + ("repeat", "repeat"), + ("attach", "attach"), + ("description", "description"), + ("summary", "summary"), + ("attendee", "attendee"), +) + class SchoolTerm(ExtensibleModel): """School term model. @@ -714,6 +725,15 @@ class Notification(ExtensibleModel, TimeStampedModel): read = models.BooleanField(default=False, verbose_name=_("Read")) sent = models.BooleanField(default=False, verbose_name=_("Sent")) + calendar_alarm = models.ForeignKey( + "CalendarAlarm", + related_name="notifications", + verbose_name=_("Calendar alarm"), + on_delete=models.CASCADE, + null=True, + blank=True, + ) + def __str__(self): return str(self.title) @@ -731,6 +751,13 @@ class Notification(ExtensibleModel, TimeStampedModel): class Meta: verbose_name = _("Notification") verbose_name_plural = _("Notifications") + constraints = [ + models.UniqueConstraint( + fields=["calendar_alarm", "recipient"], + condition=Q(calendar_alarm__isnull=False), + name="unique_recipient_per_calendar_alarm", + ), + ] class AnnouncementQuerySet(models.QuerySet): @@ -1609,6 +1636,15 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo """Return the reference object itself.""" return reference_object + @classmethod + def value_valarm( + cls, reference_object: "CalendarEvent", request: HttpRequest | None = None + ) -> list[Alarm]: + """Return all CalendarAlarms associated with the event, converted into Alarm objects.""" + return [ + calendar_alarm.get_alarm(request) for calendar_alarm in reference_object.alarms.all() + ] + @classmethod def get_objects( cls, @@ -1944,3 +1980,162 @@ class Organisation(ExtensibleModel): class Meta: verbose_name = _("Organisation") verbose_name_plural = _("Organisations") + + +class CalendarAlarm(ExtensiblePolymorphicModel): + """An alarm bound to a CalendarEvent. + + To make use of this model, you need to inherit from this model. + Every subclass of this model represents a certain group of alarms. + + Every `value_*` method from can be implemented to provide additional data + (either static or dynamic). Some like `value_action` are pre-implemented + in this model. Some like `value_action` are optional and need to be implemented + in a model inheriting from this model. The following iCal attributes are supported: + + action, trigger, duration, repeat, attach, description, summary, attendee + + Whether the implementation of some methods is required depends on the action + type of the alarm. See the iCalendar RFC 5545 documentation for more information. + """ + + ACTION_CHOICES = [ + ("audio", _("Audio")), + ("display", _("Display")), + ("email", _("Email")), + ("procedure", _("Procedure")), + ] + + event = models.ForeignKey( + CalendarEvent, on_delete=models.CASCADE, related_name="alarms", verbose_name=_("Event") + ) + + action = models.CharField( + verbose_name=_("Action"), max_length=10, default="display", choices=ACTION_CHOICES + ) + + send_notifications = models.BooleanField(verbose_name=_("Send notifications"), default=False) + + def value_action(self, request: HttpRequest | None = None) -> str: + """Return the action type of the calendar alarm. + + The action type determines in which way the alarm shall be communicated to the user. + """ + return self.action + + def value_trigger(self, request: HttpRequest | None = None) -> Union[datetime, timedelta]: + """Return the trigger of the calendar alarm. + + The trigger can be either a time delta value indicating at which time relative to the + reference event the alarm shall be triggered or a datetime value indicating an absolute + time at which this shall happen. + """ + raise NotImplementedError() + + def value_attach(self, request: HttpRequest | None = None) -> Optional[str]: + """Return the attachment of the calendar alarm.""" + if self.value_action(request) == "procedure": + raise NotImplementedError() + return None + + def value_description(self, request: HttpRequest | None = None) -> Optional[str]: + """Return the description of the calendar alarm.""" + if self.value_action(request) == "display" or self.value_action(request) == "email": + raise NotImplementedError() + return None + + def value_summary(self, request: HttpRequest | None = None) -> Optional[str]: + """Return the summary of the calendar alarm.""" + if self.value_action(request) == "email": + raise NotImplementedError() + return None + + def value_attendee(self, request: HttpRequest | None = None) -> Optional[str]: + """Return the attendees of the calendar alarm.""" + if self.value_action(request) == "email": + raise NotImplementedError() + return None + + def value_notification_recipients(self, request: HttpRequest | None = None) -> list[Person]: + """Return the recipients of the notification linked to the calendar alarm.""" + raise NotImplementedError() + + def value_notification_sender(self, request: HttpRequest | None = None) -> str: + """Return the sender of the notification linked to the calendar alarm.""" + raise NotImplementedError() + + def value_notification_title(self, request: HttpRequest | None = None) -> str: + """Return the title of the notification linked to the calendar alarm.""" + raise NotImplementedError() + + def value_notification_description(self, request: HttpRequest | None = None) -> str: + """Return the description of the notification linked to the calendar alarm.""" + return self.value_description(request) + + def value_notification_send_at(self, request: HttpRequest | None = None) -> datetime: + """Return the absolute time to send the notification linked to the calendar alarm.""" + if isinstance(self.value_trigger(request), datetime): + return self.value_trigger(request) + elif isinstance(self.value_trigger(request), timedelta): + return self.event.datetime_start - self.value_trigger(request) + + def get_alarm(self, request: Optional[HttpRequest] = None) -> Alarm: + alarm = Alarm() + for field in CALENDAR_ALARM_FIELD_MAP: + method_name = f"value_{field[0]}" + if hasattr(self, method_name) and callable(getattr(self, method_name)): + value = getattr(self, method_name)(request=request) + if value: + alarm.add(field[1], value) + return alarm + + def update_or_create_notifications( + self, request: Optional[HttpRequest] = None + ) -> Optional[list[Notification]]: + """Update or create notifications for this calendar alarm (and send them).""" + notifications = [] + + default_dict = {} + for field_name in [field.name for field in Notification._meta.get_fields()]: + method_name = f"value_notification_{field_name}" + if hasattr(self, method_name) and callable(getattr(self, method_name)): + value = getattr(self, method_name)(request=request) + if value: + default_dict[field_name] = value + + for recipient in self.value_notification_recipients(request): + try: + notification = Notification.objects.get(calendar_alarm=self, recipient=recipient) + for key, value in default_dict.items(): + setattr(notification, key, value) + notification.save() + except Notification.DoesNotExist: + new_values = {"calendar_alarm": self, "recipient": recipient} + new_values.update(default_dict) + notification = Notification(**new_values) + notification.save() + + # Using django's update_or_create creates id collisions, for some reason. + notifications.append(notification) + + return notifications + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + if self.send_notifications: + self.update_or_create_notifications() + + class Meta: + verbose_name = _("Calendar alarm") + verbose_name_plural = _("Calendar alarms") + + +class PersonalEventAlarm(CalendarAlarm): + def value_description(self, request: HttpRequest | None = None) -> Optional[str]: + """Return the description of the personal event alarm.""" + return self.event.description + + class Meta: + verbose_name = _("Personal event alarm") + verbose_name_plural = _("Personal event alarms") diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 6fdb6de794a30234cfe6817e53d45f30b4f61bad..a8f35f837c58b62cd0cd5bc111958f7f4aaa8a10 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -614,6 +614,8 @@ YARN_INSTALLED_APPS = [ "rrule", "luxon@^3.4.3", "apollo-upload-client@^18.0.1", + "@vitejs/plugin-legacy@^6.0.0", + "terser@^5.37.0", ] merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True) diff --git a/aleksis/core/templates/core/vue_index.html b/aleksis/core/templates/core/vue_index.html index 8a000ad1d5e8147183207b00fe4ea5d4cecb4844..19e192432ac050d51ca23ed3a13bead25099a346 100644 --- a/aleksis/core/templates/core/vue_index.html +++ b/aleksis/core/templates/core/vue_index.html @@ -34,6 +34,8 @@ {% block no_frontend %} {% if not need_maintenance_response %} {% vite_asset 'aleksis/core/frontend/index.js' %} + {% vite_legacy_asset 'aleksis/core/frontend/index-legacy.js' %} + {% vite_legacy_polyfills nomodule=False %} {% endif %} {% endblock no_frontend %} </body> diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index ef47395aa20f338d91f51dbfea3c58703144ebd0..3a6aa841a4d26823e24c18a34b6e9ca50a4bfff7 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -476,8 +476,8 @@ def get_ip(*args, **kwargs): return get_client_ip(*args, **kwargs)[0] -feedgenerator.FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),) -feedgenerator.ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + ( +EXTENDED_FEED_FIELD_MAP = feedgenerator.FEED_FIELD_MAP + (("color", "color"),) +EXTENDED_ITEM_ELEMENT_FIELD_MAP = feedgenerator.ITEM_ELEMENT_FIELD_MAP + ( ("color", "color"), ("meta", "x-meta"), ("reference_object", "reference_object"), @@ -496,7 +496,7 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed): cal.add("calscale", "GREGORIAN") cal.add("prodid", "-//AlekSIS//AlekSIS//EN") - for ifield, efield in feedgenerator.FEED_FIELD_MAP: + for ifield, efield in EXTENDED_FEED_FIELD_MAP: val = self.feed.get(ifield) if val is not None: cal.add(efield, val) @@ -518,7 +518,7 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed): component_type = item.get("component_type") element = Todo() if component_type == "todo" else Event() - for ifield, efield in feedgenerator.ITEM_ELEMENT_FIELD_MAP: + for ifield, efield in EXTENDED_ITEM_ELEMENT_FIELD_MAP: val = item.get(ifield) if val is not None: if ifield == "attendee": @@ -595,3 +595,4 @@ def filter_active_school_term_by_date( } ) ) + return qs diff --git a/aleksis/core/views.py b/aleksis/core/views.py index e9c908c1395a208d6c0471898d4ee758ea4608a8..f308ac0afee1ca3b95c4796d074919eafedbb61f 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -134,6 +134,9 @@ from .util.core_helpers import ( from .util.forms import PreferenceLayout from .util.pdf import render_pdf +if settings.SENTRY_ENABLED: + import sentry_sdk + class LogoView(View): def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: @@ -1265,8 +1268,17 @@ class TwoFactorLoginView(two_factor_views.LoginView): class LoggingGraphQLView(FileUploadGraphQLView): """GraphQL view that raises unknown exceptions instead of blindly catching them.""" - def execute_graphql_request(self, *args, **kwargs): - result = super().execute_graphql_request(*args, **kwargs) + def execute_graphql_request( + self, request, data, query, variables, operation_name, show_graphiql=False + ): + if settings.SENTRY_ENABLED and operation_name: + 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 + ) + errors = result.errors or [] for error in errors: if not isinstance( diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index fb43a46a0a9533a5282f1b3424078791529d3186..c08d285ed2565c56c23c20e07c0ba838c8391f77 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -33,6 +33,7 @@ import topLevelAwait from "vite-plugin-top-level-await"; import browserslistToEsbuild from "browserslist-to-esbuild"; const license = require("rollup-plugin-license"); import SupportedBrowsers from "vite-plugin-browserslist-useragent"; +import legacy from "@vitejs/plugin-legacy"; // Read the hints writen by `aleksis-admin vite` const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json")); @@ -301,6 +302,10 @@ export default defineConfig({ ignoreMinor: true, allowHigherVersions: true, }), + legacy({ + targets: browsersList, + modernPolyfills: true, + }), VitePWA({ injectRegister: "null", devOptions: { diff --git a/conftest.py b/conftest.py index 2c92d6b9540548ea2c6251381fef809cc1413679..c910cacac58bae2eaaf4a18c4006f12592ddbff1 100644 --- a/conftest.py +++ b/conftest.py @@ -33,15 +33,15 @@ def graphql_query( header_params = {"headers": headers} resp = client.post( graphql_url, - json.dumps([body]), + json.dumps(body), content_type="application/json", **header_params, ) else: resp = client.post( - graphql_url, json.dumps([body]), content_type="application/json" + graphql_url, json.dumps(body), content_type="application/json" ) - content = json.loads(resp.content)[0] + content = json.loads(resp.content) return resp, content diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index 0d35c8f65b5ce21d16d8f306368ea75ac957c92f..f7346c108c0537dc14b7eb76aef5208f7ca9c2db 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -32,7 +32,7 @@ For an installation on a dedicated server, the following prerequisites are neede * Valkey (or legacy Redis) * uWSGI * nginx - * Python 3.9 or newer + * Python 3.10 or newer * Node.js 18 or newer * Some system dependencies to build Python modules and manage frontend files * System locales for all supported languages diff --git a/pyproject.toml b/pyproject.toml index c49b40e6eb6a7d1b4ec63fde1b6f6d3a475d9964..0f98ed5790d9d58ccbf63b8ced2d4e7a48a10d40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,7 @@ django-iconify = "^0.4" customidenticon = "^0.1.5" graphene-django = ">=3.0.0, <=3.2.2" selenium = "^4.4.3" -django-vite = "^3.0.0" +django-vite = "^3.0.6" graphene-django-cud = "^0.13.0" django-ical = "^1.9.2" django-recurrence = "^1.11.1"