diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e9c9c0c9f1494a2a28cc3c839a98749d489e82cf..79c61aca13842af150e089297cf3049876d11b5d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,14 +9,52 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +The "managed models" feature is mandatory for all models derived from `ExtensibleModel` +and requires creating a migration for all downstream models to add the respective +field. + +Added +~~~~~ + +* Frontend for managing rooms. +* [Dev] Components for implementing standard CRUD operations in new frontend. +* [Dev] Options for filtering and sorting of GraphQL queries at the server. +* [Dev] Managed models for instances handled by other apps. +* [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients + +Changed +~~~~~~~ + +* Management of school terms was migrated to new frontend. + +Fixed +~~~~~ + +* [Docker] The build could silently continue even if frontend bundling failed, resulting + in an incomplete AlekSIS frontend app. +* GraphQL mutations did not return errors in case of exceptions. + +`3.1.2`_ - 2023-07-05 +--------------------- + +Changed +~~~~~~~ + +* uWSGI is now installed together with AlekSIS-Core per default. + Fixed ~~~~~ * Notifications were not properly shown in the frontend. +* [Dev] Log levels were not correctly propagated to all loggers +* [Dev] Log format did not contain all essential information * When navigating from legacy to legacy page, the latter would reload once for no reason. +* The oauth authorization page was not accessible when the service worker was active. +* [Docker] Clear obsolete bundle parts when adding apps using ONBUILD +* Extensible forms that used a subset of fields did not render properly -`3.1.1` - 2023-07-01 --------------------- +`3.1.1`_ - 2023-07-01 +--------------------- Fixed ~~~~~ @@ -171,13 +209,13 @@ Changed * Show languages in local language * Rewrite of frontend (base template) using Vuetify - * Frontend bundling migrated from Webpack to Vite (cf. installation docs) - * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the - background + * Frontend bundling migrated from Webpack to Vite (cf. installation docs) + * [Dev] The runuwsgi dev server now starts a Vite dev server with HMR in the + background * OIDC scope "profile" now exposes the avatar instead of the official photo * Based on Django 4.0 - * Use built-in Redis cache backend - * Introduce PBKDF2-SHA1 password hashing + * Use built-in Redis cache backend + * Introduce PBKDF2-SHA1 password hashing * Persistent database connections are now health-checked as to not fail requests * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check` @@ -203,8 +241,8 @@ Removed * iCal feed URLs for birthdays (will be reintroduced later) * [Dev] Django debug toolbar - * It caused major performance issues and is not useful with the new - frontend anymore + * It caused major performance issues and is not useful with the new + frontend anymore `2.12.3`_ - 2023-03-07 ---------------------- @@ -355,9 +393,7 @@ Fixed * The menu button used to be displayed twice on smaller screens. * The icons were loaded from external servers instead from local server. * Weekdays were not translated if system locales were missing - - * Added locales-all to base image and note to docs - + * Added locales-all to base image and note to docs * The icons in the account menu were still the old ones. * Due to a merge error, the once removed account menu in the sidenav appeared again. * Scheduled notifications were shown on dashboard before time. @@ -561,11 +597,9 @@ Changed * Configuration files are now deep merged by default * Improvements for shell_plus module loading - - * core.Group model now takes precedence over auth.Group - * Name collisions are resolved by prefixing with the app label - * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD - + * core.Group model now takes precedence over auth.Group + * Name collisions are resolved by prefixing with the app label + * Apps can extend SHELL_PLUS_APP_PREFIXES and SHELL_PLUS_DONT_LOAD * [Docker] Base image now contains curl, grep, less, sed, and pspg * Views raising a 404 error can now customise the message that is displayed on the error page * OpenID Connect is enabled by default now, without RSA support @@ -1188,3 +1222,4 @@ Fixed .. _3.0: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.0 .. _3.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1 .. _3.1.1: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.1 +.. _3.1.2: https://edugit.org/AlekSIS/official/AlekSIS-Core/-/tags/3.1.2 diff --git a/Dockerfile b/Dockerfile index 2134ae81150a13dbab8282149a04df0fb8d20235..4a01dcc62c57fbcef4f56299f39f08d43675fb84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,7 +126,7 @@ ONBUILD RUN set -e; \ eatmydata pip install $APPS; \ fi; \ eatmydata aleksis-admin vite build; \ - eatmydata aleksis-admin collectstatic --no-input; \ + eatmydata aleksis-admin collectstatic --no-input --clear; \ rm -rf /usr/local/share/.cache; \ eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \ eatmydata apt-get autoremove --purge -y; \ diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index c6c15e76465feb96363eb95f225549d0b65b3d92..e26390e7f7bee01ce8f63cdca699ac770c49be24 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -32,7 +32,6 @@ from .models import ( OAuthApplication, Person, PersonInvitation, - SchoolTerm, ) from .registries import ( group_preferences_registry, @@ -379,16 +378,6 @@ class EditGroupTypeForm(forms.ModelForm): fields = ["name", "description"] -class SchoolTermForm(ExtensibleForm): - """Form for managing school years.""" - - layout = Layout("name", Row("date_start", "date_end")) - - class Meta: - model = SchoolTerm - fields = ["name", "date_start", "date_end"] - - class DashboardWidgetOrderForm(ExtensibleForm): pk = forms.ModelChoiceField( queryset=None, diff --git a/aleksis/core/frontend/app/dateTimeFormats.js b/aleksis/core/frontend/app/dateTimeFormats.js index 6a980fc8dab2fea0b74fec8ef042de413db35bee..567f9a196f61052ff0481c1f975a6c0d2433964e 100644 --- a/aleksis/core/frontend/app/dateTimeFormats.js +++ b/aleksis/core/frontend/app/dateTimeFormats.js @@ -26,6 +26,10 @@ const dateTimeFormats = { hour: "numeric", minute: "numeric", }, + shortTime: { + hour: "numeric", + minute: "numeric", + }, }, de: { short: { @@ -53,6 +57,10 @@ const dateTimeFormats = { hour: "numeric", minute: "numeric", }, + shortTime: { + hour: "numeric", + minute: "numeric", + }, }, }; diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js index e2d4439a6dac88f00af6d7ff57d8bda0b963ea40..56eb8effa2da7cd552ad4aed5455f4acd7911df6 100644 --- a/aleksis/core/frontend/app/vuetify.js +++ b/aleksis/core/frontend/app/vuetify.js @@ -10,9 +10,10 @@ const vuetifyOpts = { icons: { iconfont: "mdi", // default - only for display purposes values: { - cancel: "mdi-close-circle-outline", - delete: "mdi-close-circle-outline", - success: "mdi-check-circle-outline", + cancel: "mdi-close", + delete: "mdi-close", // Not a trashcan due to vuetify using this icon inside chips for closing etc. + deleteContent: "mdi-delete-outline", + success: "mdi-check", info: "mdi-information-outline", warning: "mdi-alert-outline", error: "mdi-alert-octagon-outline", @@ -22,6 +23,11 @@ const vuetifyOpts = { checkboxIndeterminate: "mdi-minus-box-outline", edit: "mdi-pencil-outline", preferences: "mdi-cog-outline", + save: "mdi-content-save-outline", + search: "mdi-magnify", + filterEmpty: "mdi-filter-outline", + filterSet: "mdi-filter", + send: "mdi-send-outline", }, }, }; diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue index a0521f07ab974428f9bc2cfe0722874d485c6c54..6dc73749d8bb716bd32089e11afe36f6d93455c9 100644 --- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue +++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue @@ -24,7 +24,6 @@ :src="iFrameSrc" :height="iFrameHeight + 'px'" class="iframe-fullsize" - @load="load" ref="contentIFrame" ></iframe> </template> @@ -132,6 +131,9 @@ export default { }, }, mounted() { + this.$refs.contentIFrame.addEventListener("load", (e) => { + this.load(); + }); this.iFrameSrc = "/django" + this.$route.path + this.queryString; }, name: "LegacyBaseTemplate", diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index f650517ae88d122ea6684de14819c4a5f00cc241..52e2801f3953024fd5071679b92ff1a1bb716f01 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -315,4 +315,9 @@ export default { }; </script> -<style scoped></style> +<style> +div[aria-required="true"] .v-input .v-label::after { + content: " *"; + color: red; +} +</style> diff --git a/aleksis/core/frontend/components/generic/InlineCRUDList.vue b/aleksis/core/frontend/components/generic/InlineCRUDList.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a93ff8cc6c7f3a3a0b1f7bbe477f7dae1641b59 --- /dev/null +++ b/aleksis/core/frontend/components/generic/InlineCRUDList.vue @@ -0,0 +1,717 @@ +<template> + <div> + <delete-dialog + v-if="deletionEnabled" + :gql-mutation="gqlDeleteMutation" + :gql-query="$apollo.queries.items" + v-model="deletionDialog" + :item="itemToDelete" + @input="handleDeleteDone" + :item-attribute="itemTitleAttribute" + /> + + <delete-multiple-dialog + v-if="multipleDeletionEnabled" + :gql-mutation="gqlDeleteMultipleMutation" + :gql-query="$apollo.queries.items" + :items="itemsToDelete" + v-model="multipleDeletionDialog" + @input="handleDeleteDone" + :item-attribute="itemTitleAttribute" + /> + + <v-form v-model="valid"> + <v-data-table + :headers="tableHeaders" + :items="editableItems" + :loading="$apollo.loading" + :class="elevationClass" + :items-per-page="15" + :search="search" + :sort-by.sync="sortBy" + :sort-desc.sync="sortDesc" + multi-sort + @update:sort-by="handleSortChange" + @update:sort-desc="handleSortChange" + :show-select="generatedActions.length > 0" + selectable-key="canDelete" + @item-selected="handleItemSelected" + @toggle-select-all="handleToggleAll" + @current-items="checkSelectAll" + > + <template #top> + <v-toolbar flat class="height-fit child-height-fit"> + <v-row class="flex-wrap gap align-baseline pt-4"> + <v-toolbar-title class="d-flex flex-wrap w-100 gap"> + <filter-button + class="my-1 button-40" + :num-filters="numFilters" + v-if="filter" + @click="requestFilter" + @clear="clearFilters" + /> + + <filter-dialog + v-model="filterDialog" + :filters="filters" + @filters="handleFiltersChanged" + > + <template #default="slotProps"> + <slot + name="filters" + v-if="filter" + v-bind="slotProps" + ></slot> + </template> + </filter-dialog> + + <div class="my-1"> + <v-text-field + v-model="search" + type="search" + clearable + flat + filled + hide-details + single-line + prepend-inner-icon="$search" + dense + outlined + :label="$t('actions.search')" + ></v-text-field> + </div> + + <div + v-if="generatedActions.length > 0 && selectedItems.length > 0" + class="my-1" + > + <v-autocomplete + auto-select-first + clearable + :items="generatedActions" + v-model="selectedAction" + return-object + :label="$t('actions.select_action')" + item-text="name" + outlined + dense + :hint=" + $tc('selection.num_items_selected', selectedItems.length) + " + persistent-hint + append-outer-icon="$send" + @click:append-outer="handleAction" + > + <template #item="{ item, attrs, on }"> + <v-list-item dense v-bind="attrs" v-on="on"> + <v-list-item-icon v-if="item.icon"> + <v-icon>{{ item.icon }}</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title>{{ item.name }}</v-list-item-title> + </v-list-item-content> + </v-list-item> + </template> + </v-autocomplete> + </div> + </v-toolbar-title> + + <v-spacer + class="flex-grow-0 flex-sm-grow-1 mx-n1 mx-sm-0" + ></v-spacer> + <slot + v-if="!editMode && showCreate" + name="createComponent" + :attrs="{ + value: createMode, + getCreateData: getCreateData, + defaultItem: defaultItem, + gqlQuery: gqlQuery, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: true, + fields: editableHeaders, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (i ? requestCreate() : null), + cancel: cancelCreate, + save: handleCreateDone, + error: handleError, + }" + :create-mode="createMode" + :form-field-slot-name="formFieldSlotName" + > + <dialog-object-form + v-model="createMode" + :get-create-data="getCreateData" + :default-item="defaultItem" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :is-create="true" + :fields="editableHeaders" + :create-item-i18n-key="createItemI18nKey" + @cancel="cancelCreate" + @save="handleCreateDone" + @error="handleError" + > + <template #activator="{ props }"> + <create-button + color="secondary" + @click="requestCreate" + :disabled="createMode" + /> + </template> + + <template + v-for="header in editableHeaders" + #[formFieldSlotName(header)]="{ item, isCreate, on, attrs }" + > + <slot + :name="formFieldSlotName(header)" + :attrs="attrs" + :on="on" + :item="item" + :is-create="isCreate" + /> + </template> + </dialog-object-form> + </slot> + <edit-button + v-if="!editMode && editingEnabled" + @click="requestEdit" + :disabled="createMode" + /> + <cancel-button v-if="editMode" @click="cancelEdit" /> + <save-button + v-if="editMode" + @click="saveEdit" + :loading="loading" + :disabled="!valid" + /> + </v-row> + </v-toolbar> + </template> + + <template + v-for="(header, idx) in headers" + #[tableItemSlotName(header)]="{ item }" + > + <v-scroll-x-transition mode="out-in" :key="idx"> + <span key="value" v-if="!editMode || header.disableEdit"> + <slot :name="header.value" :item="item">{{ + item[header.value] + }}</slot> + </span> + <span key="field" v-else-if="editMode"> + <slot + :name="header.value + '.field'" + :item="item" + :is-create="false" + :attrs="buildAttrs(item[header.value])" + :on="buildOn(dynamicSetter(item, header.value))" + > + <v-text-field + filled + dense + hide-details="auto" + v-model="item[header.value]" + ></v-text-field> + </slot> + </span> + </v-scroll-x-transition> + </template> + + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #item.actions="{ item }"> + <slot name="actions" :item="item" /> + <v-btn + v-if="'canDelete' in item && item.canDelete" + icon + :title="$t(`actions.delete`)" + color="error" + @click="handleDeleteClick(item)" + > + <v-icon>$deleteContent</v-icon> + </v-btn> + </template> + </v-data-table> + </v-form> + + <closable-snackbar :color="snackbarState" v-model="snackbar"> + {{ snackbarText }} + </closable-snackbar> + </div> +</template> + +<script> +import CreateButton from "./buttons/CreateButton.vue"; +import EditButton from "./buttons/EditButton.vue"; +import SaveButton from "./buttons/SaveButton.vue"; +import CancelButton from "./buttons/CancelButton.vue"; +import DeleteDialog from "./dialogs/DeleteDialog.vue"; +import DeleteMultipleDialog from "./dialogs/DeleteMultipleDialog.vue"; +import DialogObjectForm from "./dialogs/DialogObjectForm.vue"; +import ClosableSnackbar from "./dialogs/ClosableSnackbar.vue"; +import FilterButton from "./buttons/FilterButton.vue"; +import FilterDialog from "./dialogs/FilterDialog.vue"; + +export default { + name: "InlineCRUDList", + components: { + FilterDialog, + FilterButton, + ClosableSnackbar, + DeleteDialog, + DeleteMultipleDialog, + DialogObjectForm, + CancelButton, + SaveButton, + EditButton, + CreateButton, + }, + + apollo: { + items() { + return { + query: this.gqlQuery, + variables() { + return { + orderBy: this.gqlOrderBy, + filters: this.filterString, + }; + }, + error: function (error) { + this.handleError(error); + }, + result: function (data) { + this.editableItems = data.data + ? this.getGqlData(JSON.parse(JSON.stringify(data.data.items))) + : []; + }, + }; + }, + }, + data() { + return { + editMode: false, + createMode: false, + loading: false, + createModel: {}, + editableItems: [], + snackbar: false, + snackbarText: null, + snackbarState: "success", + valid: false, + deletionDialog: false, + multipleDeletionDialog: false, + itemToDelete: null, + itemsToDelete: [], + search: "", + filterDialog: false, + filters: {}, + filterString: "{}", + sortBy: [], + sortDesc: [], + gqlOrderBy: [], + selectedAction: null, + selectedItems: [], + allSelected: false, + }; + }, + computed: { + tableHeaders() { + return this.headers + .concat( + this.deletionEnabled + ? [ + { + text: this.$t("actions.title"), + value: "actions", + sortable: false, + align: "right", + }, + ] + : [] + ) + .filter((header) => this.hiddenColumns.indexOf(header.value) === -1); + }, + editableHeaders() { + return this.headers.filter((header) => !header.disableEdit); + }, + elevationClass() { + return this.elevated ? "elevation-2" : ""; + }, + editingEnabled() { + return ( + this.gqlPatchMutation && this.items && this.items.some((i) => i.canEdit) + ); + }, + deletionEnabled() { + return ( + this.gqlDeleteMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + multipleDeletionEnabled() { + return ( + this.multipleDeletion && + this.gqlDeleteMultipleMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + numFilters() { + // This needs to use the json string, as vue reactivity doesn't work for objects with dynamic properties + return Object.keys(JSON.parse(this.filterString)).length; + }, + generatedActions() { + if (!this.multipleDeletionEnabled) { + return this.actions; + } + return [ + ...this.actions, + { + name: this.$t("actions.delete"), + icon: "$deleteContent", + handler: (items) => { + this.itemsToDelete = items; + this.multipleDeletionDialog = true; + }, + clearSelection: true, + }, + ]; + }, + }, + props: { + i18nKey: { + type: String, + required: true, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + createSuccessMessageKey: { + type: String, + required: false, + default: "status.object_create_success", + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMultipleMutation: { + type: Object, + required: false, + default: undefined, + }, + headers: { + type: Array, + required: true, + }, + itemTitleAttribute: { + type: String, + required: false, + default: "name", + }, + defaultItem: { + type: Object, + required: true, + }, + showCreate: { + type: Boolean, + required: false, + default: true, + }, + elevated: { + type: Boolean, + required: false, + default: true, + }, + hiddenColumns: { + type: Array, + required: false, + default: () => [], + }, + getGqlData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (items, headers) => { + return items.map((item) => { + let dto = {}; + headers.map((header) => (dto[header.value] = item[header.value])); + return dto; + }); + }, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + filter: { + type: Boolean, + required: false, + default: false, + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + multipleDeletion: { + type: Boolean, + required: false, + default: true, + }, + }, + methods: { + requestCreate() { + if (this.loading) return; + + this.createMode = true; + this.editMode = false; + }, + requestEdit() { + if (this.loading) return; + + this.editMode = true; + this.createMode = false; + }, + saveEdit() { + this.loading = true; + + if (!this.editableItems || !this.editingEnabled) return; + + this.$apollo + .mutate({ + mutation: this.gqlPatchMutation, + variables: { + input: this.getPatchData( + this.editableItems, + this.headers.concat({ title: "id", value: "id" }) + ), + }, + }) + .then((data) => { + this.items = data.data.batchMutation.items; + this.editableItems = this.getGqlData(data.data.batchMutation.items); + + this.handleSuccess("status.saved"); + }) + .catch((error) => { + this.handleError(error); + }) + .finally(() => { + this.loading = false; + this.editMode = false; + }); + }, + cancelEdit() { + this.editMode = false; + this.editableItems = this.getGqlData( + JSON.parse(JSON.stringify(this.items)) + ); + }, + saveCreate() { + if (!this.gqlCreateMutation) return; + + this.loading = true; + this.$apollo + .mutate({ + mutation: this.gqlCreateMutation, + variables: { + input: this.createModel, + }, + }) + .then((data) => { + this.$apollo.queries.items.refetch(); + this.createModel = {}; + }) + .catch((error) => { + this.handleError(error); + }) + .finally(() => { + this.loading = false; + this.createMode = false; + }); + }, + cancelCreate() { + this.createMode = false; + this.createModel = {}; + }, + tableItemSlotName(headerEntry) { + return "item." + headerEntry.value; + }, + formFieldSlotName(headerEntry) { + return headerEntry.value + ".field"; + }, + dynamicSetter(item, fieldName) { + return (value) => { + this.$set(item, fieldName, value); + }; + }, + handleError(error) { + console.error(error); + if (error instanceof String) { + // error is a translation key or simply a string + this.snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + this.snackbarText = error.message; + } else { + this.snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.snackbarState = "error"; + this.snackbar = true; + }, + handleSuccess(success) { + this.snackbarText = this.$t( + success || "graphql.snackbar_success_message" + ); + + this.snackbarState = "success"; + this.snackbar = true; + }, + handleDeleteClick(item) { + if (!item) { + console.warn("Delete handler called without item parameter"); + return; + } + + this.itemToDelete = item; + this.deletionDialog = true; + }, + handleDeleteDone() { + this.itemToDelete = null; + this.itemsToDelete = []; + }, + handleCreateDone() { + this.$apollo.queries.items.refetch(); + this.createMode = false; + }, + requestFilter() { + if (this.filter) { + this.filterDialog = true; + } + }, + handleFiltersChanged(event) { + this.filters = event; + this.filterString = JSON.stringify(this.filters); + }, + clearFilters() { + this.handleFiltersChanged({}); + }, + buildAttrs(value) { + return { + dense: true, + filled: true, + hideDetails: "auto", + value: value, + inputValue: value, + }; + }, + buildOn(setter) { + return { + input: setter, + change: setter, + }; + }, + snakeCase(string) { + return string + .replace(/\W+/g, " ") + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join("_"); + }, + orderKey(value, desc) { + const key = + this.headers.find((header) => header.value === value).orderKey || + this.snakeCase(value); + return (desc ? "-" : "") + key; + }, + handleSortChange() { + this.gqlOrderBy = this.sortBy.map((value, key) => + this.orderKey(value, this.sortDesc[key]) + ); + }, + handleItemSelected({ item, value }) { + if (value) { + this.selectedItems.push(item); + } else { + const index = this.selectedItems.indexOf(item); + if (index >= 0) { + this.selectedItems.splice(index, 1); + } + } + }, + handleToggleAll({ items, value }) { + if (value) { + // There is a bug in vuetify: items contains all elements, even those that aren't selectable + this.selectedItems = items.filter((item) => item.canDelete || false); + } else { + this.selectedItems = []; + } + this.allSelected = value; + }, + checkSelectAll(newItems) { + if (this.allSelected) { + this.handleToggleAll({ + items: newItems, + value: true, + }); + } + }, + handleAction() { + if (this.selectedAction) { + this.selectedAction.handler(this.selectedItems); + + if (this.selectedAction.clearSelection) { + this.selectedItems = []; + } + + this.selectedAction = null; + } + }, + }, + mounted() { + this.$setToolBarTitle(this.$t(`${this.i18nKey}.title_plural`), null); + }, +}; +</script> + +<style> +.gap { + gap: 0.5rem; +} +.height-fit, +.child-height-fit > * { + height: fit-content !important; +} + +.button-40 { + min-height: 40px; +} +</style> diff --git a/aleksis/core/frontend/components/generic/ObjectCRUDList.vue b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue new file mode 100644 index 0000000000000000000000000000000000000000..be792e3d825ec0d98c54a21b81f9b3a385f82203 --- /dev/null +++ b/aleksis/core/frontend/components/generic/ObjectCRUDList.vue @@ -0,0 +1,331 @@ +<script setup> +import CreateButton from "./buttons/CreateButton.vue"; +import DialogObjectForm from "./dialogs/DialogObjectForm.vue"; +</script> + +<template> + <v-card> + <v-data-iterator + :items="items" + :items-per-page="itemsPerPage" + :loading="$apollo.queries.items.loading" + hide-default-footer + > + <template #loading> + <slot name="loading"> + <v-skeleton-loader + type="card-heading, list-item-avatar-two-line@3, actions" + /> + </slot> + </template> + + <template #no-data> + <v-card-text>{{ $t(noItemsI18nKey) }}</v-card-text> + </template> + + <template #header> + <v-card-title>{{ title }}</v-card-title> + </template> + + <template #default="props"> + <slot + v-if="items.length" + name="iteratorContent" + :items="props.items" + :editing-enabled="editingEnabled" + :deletion-enabled="deletionEnabled" + :handle-edit="handleEdit" + :handle-delete="handleDelete" + > + <v-list> + <template v-for="(item, index) in items"> + <v-list-item :key="item.id"> + <v-list-item-avatar> + <slot + name="listIteratorItemAvatar" + :item="item" + :index="index" + /> + </v-list-item-avatar> + <v-list-item-content> + <slot + name="listIteratorItemContent" + :item="item" + :index="index" + > + <v-list-item-title> + {{ item.name }} + </v-list-item-title> + </slot> + </v-list-item-content> + <v-list-item-action> + <v-btn + v-if="editingEnabled && item.canEdit" + icon + @click="handleEdit(item)" + > + <v-icon>mdi-pencil-outline</v-icon> + </v-btn> + <v-btn + v-if="deletionEnabled && item.canDelete" + icon + @click="handleDelete(item)" + > + <v-icon>mdi-delete-outline</v-icon> + </v-btn> + </v-list-item-action> + </v-list-item> + <v-divider + v-if="index < items.length - 1" + :key="index" + inset + ></v-divider> + </template> + </v-list> + </slot> + </template> + + <template #footer> + <v-card-actions> + <slot + v-if="creatingEnabled || editingEnabled" + name="createComponent" + :attrs="{ + value: objectFormModel, + defaultItem: defaultItem, + editItem: editItem, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: isCreate, + fields: fields, + getCreateData: getCreateData, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (objectFormModel = i), + cancel: () => (objectFormModel = false), + save: handleCreateDone, + error: handleError, + }" + > + <dialog-object-form + v-model="objectFormModel" + :get-create-data="getCreateData" + :get-patch-data="getPatchData" + :default-item="defaultItem" + :edit-item="editItem" + :force-model-item-update="true" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :is-create="isCreate" + :fields="fields" + :create-item-i18n-key="createItemI18nKey" + @cancel="objectFormModel = false" + @save="handleCreateDone" + @error="handleError" + > + <template #activator="{ props }" v-if="creatingEnabled"> + <create-button + @click="handleCreate" + :disabled="objectFormModel" + /> + </template> + + <template + v-for="field in fields" + #[formFieldSlotName(field)]="{ item, isCreate, on, attrs }" + > + <slot + :name="formFieldSlotName(field)" + :attrs="attrs" + :on="on" + :item="item" + :is-create="isCreate" + /> + </template> + </dialog-object-form> + </slot> + </v-card-actions> + </template> + </v-data-iterator> + </v-card> +</template> + +<script> +export default { + name: "ObjectCRUDList", + props: { + titleI18nKey: { + type: String, + required: false, + default: "", + }, + titleString: { + type: String, + required: false, + default: "", + }, + noItemsI18nKey: { + type: String, + required: true, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + fields: { + type: Array, + required: false, + default: undefined, + }, + defaultItem: { + type: Object, + required: false, + default: undefined, + }, + getGqlData: { + type: Function, + required: false, + default: (data) => data.items, + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlVariables: { + type: Object, + required: false, + default: undefined, + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlDeleteMutation: { + type: Object, + required: false, + default: undefined, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (item) => { + let { id, __typename, ...patchItem } = item; + return patchItem; + }, + }, + itemsPerPage: { + type: Number, + required: false, + default: 5, + }, + }, + components: { + CreateButton, + }, + data() { + return { + objectFormModel: false, + editItem: undefined, + isCreate: true, + }; + }, + apollo: { + items() { + return { + query: this.gqlQuery, + variables() { + if (this.gqlVariables) { + return this.gqlVariables; + } + return {}; + }, + error: function (error) { + this.handleError(error); + }, + update(data) { + return this.getGqlData(data); + }, + }; + }, + }, + methods: { + handleCreate() { + this.editItem = undefined; + this.isCreate = true; + this.objectFormModel = true; + }, + handleEdit(item) { + if (!item || !this.editingEnabled) { + return; + } + + this.editItem = item; + this.isCreate = false; + this.objectFormModel = true; + }, + handleDelete() {}, + handleCreateDone() { + this.$apollo.queries.items.refetch(); + }, + handleError(error) { + console.error(error); + let snackbarText = ""; + if (error instanceof String) { + // error is a translation key or simply a string + snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + snackbarText = error.message; + } else { + snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: snackbarText, + color: "error", + }); + }, + formFieldSlotName(headerEntry) { + return headerEntry.value + ".field"; + }, + }, + computed: { + creatingEnabled() { + return this.gqlCreateMutation && this.fields && this.defaultItem; + }, + editingEnabled() { + return ( + this.gqlPatchMutation && + this.fields && + this.items && + this.items.some((i) => i.canEdit) + ); + }, + deletionEnabled() { + return ( + this.gqlDeleteMutation && + this.items && + this.items.some((i) => i.canDelete) + ); + }, + title() { + return this.titleI18nKey ? this.$t(this.titleI18nKey) : this.titleString; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..5dd63039fe22d0d57534eb2f62f039ea101f2897 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue @@ -0,0 +1,29 @@ +<template> + <v-btn v-bind="{ ...$props, ...$attrs }" @click="$emit('click', $event)"> + <slot> + <v-icon v-if="iconText" left>{{ iconText }}</v-icon> + <span v-t="i18nKey" /> + </slot> + </v-btn> +</template> + +<script> +export default { + name: "BaseButton", + inheritAttrs: true, + extends: "v-btn", + props: { + i18nKey: { + type: String, + required: true, + }, + iconText: { + type: String, + required: false, + default: undefined, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/buttons/CancelButton.vue b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..2d12aa4576106a3bce51f77bfd8e0d401f2ab16a --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/CancelButton.vue @@ -0,0 +1,25 @@ +<script> +import SecondaryActionButton from "./SecondaryActionButton.vue"; + +export default { + name: "CancelButton", + extends: SecondaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$cancel", + }, + i18nKey: { + type: String, + required: false, + default: "actions.cancel", + }, + color: { + type: String, + required: false, + default: "error", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/CreateButton.vue b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..6a03704c176f9bfaf952a1529cbad4ed6c996846 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/CreateButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "CreateButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$plus", + }, + i18nKey: { + type: String, + required: false, + default: "actions.create", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..d742345b799e20d860cca01796f75d85e0d0d6a5 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/DeleteButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "DeleteButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$delete", + }, + i18nKey: { + type: String, + required: false, + default: "actions.delete", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/EditButton.vue b/aleksis/core/frontend/components/generic/buttons/EditButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..45c6918bfb18c19796e5a6e3fc8ccca9e1542b65 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/EditButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "EditButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$edit", + }, + i18nKey: { + type: String, + required: false, + default: "actions.edit", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..5b7a2435dcd11b2ee29f178f34c02a0138318453 --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue @@ -0,0 +1,55 @@ +<template> + <secondary-action-button + v-bind="$attrs" + v-on="$listeners" + :i18n-key="i18nKey" + > + <v-icon v-if="icon" left>{{ icon }}</v-icon> + <v-badge color="secondary" :value="numFilters" :content="numFilters" inline> + <span v-t="i18nKey" /> + </v-badge> + <v-btn + icon + @click.stop="$emit('clear')" + small + v-if="numFilters" + class="mr-n1" + > + <v-icon>$clear</v-icon> + </v-btn> + </secondary-action-button> +</template> + +<script> +import SecondaryActionButton from "./SecondaryActionButton.vue"; + +export default { + name: "FilterButton", + components: { SecondaryActionButton }, + extends: SecondaryActionButton, + computed: { + icon() { + return this.hasFilters || this.numFilters > 0 + ? "$filterSet" + : "$filterEmpty"; + }, + }, + props: { + i18nKey: { + type: String, + required: false, + default: "actions.filter", + }, + hasFilters: { + type: Boolean, + required: false, + default: false, + }, + numFilters: { + type: Number, + required: false, + default: 0, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..b33e8fe073a0e8b62b03fe593564b91a8e375ebb --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/PrimaryActionButton.vue @@ -0,0 +1,19 @@ +<script> +import BaseButton from "./BaseButton.vue"; +export default { + name: "PrimaryActionButton", + components: { BaseButton }, + extends: BaseButton, + props: { + i18nKey: { + type: String, + required: true, + }, + color: { + type: String, + required: false, + default: "primary", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/SaveButton.vue b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..16380f83f56c965c1eacbc66f541c0020fedb6fd --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/SaveButton.vue @@ -0,0 +1,20 @@ +<script> +import PrimaryActionButton from "./PrimaryActionButton.vue"; + +export default { + name: "SaveButton", + extends: PrimaryActionButton, + props: { + iconText: { + type: String, + required: false, + default: "$save", + }, + i18nKey: { + type: String, + required: false, + default: "actions.save", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..209d10d102e4f45a26efa72e03c1d00b39378caf --- /dev/null +++ b/aleksis/core/frontend/components/generic/buttons/SecondaryActionButton.vue @@ -0,0 +1,24 @@ +<script> +import BaseButton from "./BaseButton.vue"; +export default { + name: "SecondaryActionButton", + components: { BaseButton }, + extends: BaseButton, + props: { + i18nKey: { + type: String, + required: true, + }, + color: { + type: String, + required: false, + default: "secondary", + }, + outlined: { + type: Boolean, + required: false, + default: true, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..16fd418c34bbc3f3ffe3549e3a86635ffd45ec11 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue @@ -0,0 +1,24 @@ +<template> + <v-snackbar v-bind="$attrs" v-on="$listeners"> + <slot /> + <template #action="{ attrs }"> + <v-btn v-bind="attrs" @click="close()" icon> + <v-icon>$close</v-icon> + </v-btn> + </template> + </v-snackbar> +</template> + +<script> +export default { + name: "ClosableSnackbar", + extends: "v-snackbar", + methods: { + close() { + this.$emit("input", false); + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue index a56e3960cd1ae29e60f966a44ae69adeb0332949..4824d2e8f6eaf3c5046de9611b6bf47458ceb195 100644 --- a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue +++ b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue @@ -1,3 +1,9 @@ +<script setup> +import CancelButton from "../buttons/CancelButton.vue"; +import DeleteButton from "../buttons/DeleteButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +</script> + <template> <ApolloMutation v-if="dialogOpen" @@ -7,39 +13,29 @@ @done="close(true)" > <template #default="{ mutate, loading, error }"> - <v-dialog v-model="dialogOpen" max-width="500px"> - <v-card> - <v-card-title class="text-h5"> - <slot name="title"> - {{ $t("actions.confirm_deletion") }} - </slot> - </v-card-title> - <v-card-text> - <slot name="body"> - <p class="text-body-1">{{ nameOfObject }}</p> + <mobile-fullscreen-dialog v-model="dialogOpen"> + <template #title> + <slot name="title"> + {{ $t("actions.confirm_deletion") }} + </slot> + </template> + <template #content> + <slot name="body"> + <p class="text-body-1">{{ nameOfObject }}</p> + </slot> + </template> + <template #actions> + <cancel-button @click="close(false)" :disabled="loading"> + <slot name="cancelContent"> + <v-icon left>$cancel</v-icon> + {{ $t("actions.cancel") }} </slot> - </v-card-text> - <v-card-actions> - <v-spacer></v-spacer> - <v-btn text @click="close(false)" :disabled="loading"> - <slot name="cancelContent"> - {{ $t("actions.cancel") }} - </slot> - </v-btn> - <v-btn - color="error" - text - @click="mutate" - :loading="loading" - :disabled="loading" - > - <slot name="deleteContent"> - {{ $t("actions.delete") }} - </slot> - </v-btn> - </v-card-actions> - </v-card> - </v-dialog> + </cancel-button> + <delete-button @click="mutate" :loading="loading" :disabled="loading"> + <slot name="deleteContent" /> + </delete-button> + </template> + </mobile-fullscreen-dialog> <v-snackbar :value="error !== null"> {{ error }} @@ -71,16 +67,27 @@ export default { this.$emit("input", val); }, }, + query() { + if ("options" in this.gqlQuery) { + return { + ...this.gqlQuery.options, + variables: JSON.parse(this.gqlQuery.previousVariablesJson), + }; + } + return { query: this.gqlQuery }; + }, }, methods: { update(store) { + this.$emit("update", store); + if (!this.gqlQuery) { // There is no GraphQL query to update return; } // Read the data from cache for query - const storedData = store.readQuery({ query: this.gqlQuery }); + const storedData = store.readQuery(this.query); if (!storedData) { // There are no data in the cache yet @@ -96,12 +103,19 @@ export default { storedData[storedDataKey].splice(index, 1); // Write data back to the cache - store.writeQuery({ query: this.gqlQuery, data: storedData }); + store.writeQuery({ ...this.query, data: storedData }); }, close(success) { this.$emit("input", false); if (success) { this.$emit("success"); + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: this.$t(this.deleteSuccessMessageI18nKey), + color: "success", + }); } else { this.$emit("cancel"); } @@ -131,6 +145,11 @@ export default { required: false, default: null, }, + deleteSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_delete_success", + }, }, }; </script> diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..1e09c18ba6a517a6c053c2faac09e35bbfc62f71 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/DeleteMultipleDialog.vue @@ -0,0 +1,161 @@ +<script setup> +import CancelButton from "../buttons/CancelButton.vue"; +import DeleteButton from "../buttons/DeleteButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +</script> + +<template> + <ApolloMutation + v-if="dialogOpen" + :mutation="gqlMutation" + :variables="{ ids: ids }" + :update="update" + @done="close(true)" + > + <template #default="{ mutate, loading, error }"> + <mobile-fullscreen-dialog v-model="dialogOpen"> + <template #title> + <slot name="title"> + {{ $t("actions.confirm_deletion_multiple") }} + </slot> + </template> + <template #content> + <slot name="body"> + <ul class="text-body-1"> + <li v-for="(item, idx) in items" :key="idx"> + {{ nameOfItem(item) }} + </li> + </ul> + </slot> + </template> + <template #actions> + <cancel-button @click="close(false)" :disabled="loading"> + <slot name="cancelContent"> + <v-icon left>$cancel</v-icon> + {{ $t("actions.cancel") }} + </slot> + </cancel-button> + <delete-button @click="mutate" :loading="loading" :disabled="loading"> + <slot name="deleteContent" /> + </delete-button> + </template> + </mobile-fullscreen-dialog> + </template> + </ApolloMutation> +</template> + +<script> +export default { + name: "DeleteDialog", + computed: { + dialogOpen: { + get() { + return this.value; + }, + + set(val) { + this.$emit("input", val); + }, + }, + ids() { + return this.items.map((item) => item[this.itemId]); + }, + query() { + if ("options" in this.gqlQuery) { + return { + ...this.gqlQuery.options, + variables: JSON.parse(this.gqlQuery.previousVariablesJson), + }; + } + return { query: this.gqlQuery }; + }, + }, + methods: { + update(store) { + this.$emit("update", store); + + if (!this.gqlQuery) { + // There is no GraphQL query to update + return; + } + + // Read the data from cache for query + const storedData = store.readQuery(this.query); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const storedDataKey = Object.keys(storedData)[0]; + + for (const item of this.items) { + console.debug("Removing item from store:", item); + // Remove item from stored data + const index = storedData[storedDataKey].findIndex( + (m) => m.id === item.id + ); + storedData[storedDataKey].splice(index, 1); + } + + // Write data back to the cache + store.writeQuery({ ...this.query, data: storedData }); + }, + close(success) { + this.$emit("input", false); + if (success) { + this.$emit("success"); + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: this.$t(this.deleteSuccessMessageI18nKey), + color: "success", + }); + } else { + this.$emit("cancel"); + } + }, + nameOfItem(item) { + return this.itemAttribute in item || {} + ? item[this.itemAttribute] + : item.toString(); + }, + }, + props: { + value: { + type: Boolean, + required: true, + }, + items: { + type: Array, + required: false, + default: () => [], + }, + itemAttribute: { + type: String, + required: false, + default: "name", + }, + itemId: { + type: String, + required: false, + default: "id", + }, + gqlMutation: { + type: Object, + required: true, + }, + gqlQuery: { + type: Object, + required: false, + default: null, + }, + deleteSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.objects_delete_success", + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..90bf452b66cd7c1aa0ab2843e005ef7f82c4e84b --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue @@ -0,0 +1,250 @@ +<template> + <mobile-fullscreen-dialog v-model="dialog" max-width="500px"> + <template #activator="{ on, attrs }"> + <slot name="activator" v-bind="{ on, attrs }" /> + </template> + + <template #title> + <slot name="title"> + <span class="text-h5">{{ + isCreate ? $t(createItemI18nKey) : $t(editItemI18nKey) + }}</span> + </slot> + </template> + + <template #content> + <v-form v-model="valid"> + <v-container> + <v-row> + <v-col cols="12" sm="6" v-for="field in fields" :key="field.value"> + <slot + :label="field.text" + :name="field.value + '.field'" + :attrs="buildAttrs(itemModel, field)" + :on="buildOn(dynamicSetter(itemModel, field.value))" + :is-create="isCreate" + :item="itemModel" + > + <v-text-field + :label="field.text" + filled + v-model="itemModel[field.value]" + ></v-text-field> + </slot> + </v-col> + </v-row> + </v-container> + </v-form> + </template> + + <template #actions> + <cancel-button @click="$emit('cancel')" /> + <save-button @click="save" :loading="loading" :disabled="!valid" /> + </template> + </mobile-fullscreen-dialog> +</template> + +<script> +import SaveButton from "../buttons/SaveButton.vue"; +import CancelButton from "../buttons/CancelButton.vue"; +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; + +export default { + name: "DialogObjectForm", + components: { + CancelButton, + SaveButton, + MobileFullscreenDialog, + }, + data() { + return { + loading: false, + valid: false, + firstInitDone: false, + itemModel: {}, + }; + }, + props: { + value: { + type: Boolean, + default: false, + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + createSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_create_success", + }, + editItemI18nKey: { + type: String, + required: false, + default: "actions.edit", + }, + editSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_edit_success", + }, + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + fields: { + type: Array, + required: true, + }, + itemTitleAttribute: { + type: String, + required: false, + default: "name", + }, + defaultItem: { + type: Object, + required: true, + }, + editItem: { + type: Object, + required: false, + default: undefined, + }, + forceModelItemUpdate: { + type: Boolean, + required: false, + default: false, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + getPatchData: { + type: Function, + required: false, + default: (item) => { + let { id, __typename, ...patchItem } = item; + return patchItem; + }, + }, + isCreate: { + type: Boolean, + required: true, + }, + }, + computed: { + dialog: { + get() { + return this.value; + }, + set(newValue) { + this.$emit("input", newValue); + }, + }, + }, + methods: { + save() { + this.loading = true; + + if ( + !this.itemModel || + (this.isCreate && !this.gqlCreateMutation) || + (!this.isCreate && !this.gqlPatchMutation) + ) + return; + + let mutation = this.isCreate + ? this.gqlCreateMutation + : this.gqlPatchMutation; + + let variables = this.isCreate + ? { input: this.getCreateData(this.itemModel) } + : { input: this.getPatchData(this.itemModel), id: this.itemModel.id }; + + this.$apollo + .mutate({ + mutation: mutation, + variables: variables, + update: (store, data) => { + this.$emit( + "update", + store, + data.data[mutation.definitions[0].name.value].item + ); + }, + }) + .then((data) => { + this.$emit("save", data); + + this.handleSuccess(); + }) + .catch((error) => { + console.error(error); + this.$emit("error", error); + }) + .finally(() => { + this.loading = false; + this.dialog = false; + }); + }, + dynamicSetter(item, fieldName) { + return (value) => { + this.$set(item, fieldName, value); + }; + }, + buildAttrs(item, field) { + return { + dense: true, + filled: true, + value: item[field.value], + inputValue: item[field.value], + label: field.text, + }; + }, + buildOn(setter) { + return { + input: setter, + change: setter, + }; + }, + handleSuccess() { + let snackbarTextKey = this.isCreate + ? this.createSuccessMessageI18nKey + : this.editSuccessMessageI18nKey; + + this.$root.snackbarItems.push({ + id: crypto.randomUUID(), + timeout: 5000, + messageKey: snackbarTextKey, + color: "success", + }); + }, + updateModel() { + // Only update the model if the dialog is hidden or has just been mounted + if (this.forceModelItemUpdate || !this.firstInitDone || !this.dialog) { + this.itemModel = JSON.parse( + JSON.stringify(this.isCreate ? this.defaultItem : this.editItem) + ); + } + }, + }, + mounted() { + this.updateModel(); + this.firstInitDone = true; + + this.$watch("isCreate", this.updateModel); + this.$watch("defaultItem", this.updateModel, { deep: true }); + this.$watch("editItem", this.updateModel, { deep: true }); + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..24561d9f5bbe55fb53d6504edb35df756bcb8947 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/FilterDialog.vue @@ -0,0 +1,78 @@ +<template> + <mobile-fullscreen-dialog v-bind="$attrs" v-on="$listeners"> + <template #title>{{ $t("actions.filter") }}</template> + + <template #content> + <form ref="form" @submit.prevent="save"> + <slot :attrs="attrs" :on="on"></slot> + </form> + </template> + + <template #actions> + <cancel-button + i18n-key="actions.clear_filters" + @click="clearFilters" + ></cancel-button> + <save-button + i18n-key="actions.filter" + icon-text="$filterEmpty" + @click="save" + ></save-button> + </template> + </mobile-fullscreen-dialog> +</template> + +<script> +import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +import CancelButton from "../buttons/CancelButton.vue"; +import SaveButton from "../buttons/SaveButton.vue"; + +export default { + name: "FilterDialog", + components: { SaveButton, CancelButton, MobileFullscreenDialog }, + props: { + filters: { + type: Object, + required: true, + }, + }, + methods: { + save() { + // Drop values that are null, as we don't want to apply empty filter + for (const key in this.filters) { + if (key in this.filters && this.filters[key] === null) { + // eslint-disable-next-line vue/no-mutating-props + delete this.filters[key]; + } + } + + this.$emit("filters", this.filters); + this.$emit("input", false); + }, + clearFilters() { + this.$refs.form.reset(); + this.$emit("filters", {}); + this.$emit("input", false); + }, + on(field) { + return { + // eslint-disable-next-line vue/no-mutating-props + change: (i) => (this.filters[field] = i), + // eslint-disable-next-line vue/no-mutating-props + input: (i) => (this.filters[field] = i), + }; + }, + attrs(field, defaultValue) { + if ([null, undefined].includes(this.filters[field]) && !!defaultValue) { + // eslint-disable-next-line vue/no-mutating-props + this.filters[field] = defaultValue; + } + return { + value: this.filters[field], + }; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..91df04e68bfff958105a377d1049211497a81a5e --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/MobileFullscreenDialog.vue @@ -0,0 +1,40 @@ +<template> + <v-dialog + v-bind="$attrs" + v-on="$listeners" + :fullscreen="$vuetify.breakpoint.xs" + :hide-overlay="$vuetify.breakpoint.xs" + max-width="600px" + > + <template #activator="activator"> + <slot name="activator" v-bind="activator"></slot> + </template> + <template #default> + <slot> + <v-card class="d-flex flex-column"> + <v-card-title> + <slot name="title"></slot> + </v-card-title> + <v-card-text> + <slot name="content"></slot> + </v-card-text> + <v-spacer /> + <v-divider /> + <v-card-actions> + <v-spacer></v-spacer> + <slot name="actions"></slot> + </v-card-actions> + </v-card> + </slot> + </template> + </v-dialog> +</template> + +<script> +export default { + name: "MobileFullscreenDialog", + extends: "v-dialog", +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/ColorField.vue b/aleksis/core/frontend/components/generic/forms/ColorField.vue new file mode 100644 index 0000000000000000000000000000000000000000..6f11516c877fe4a2a9df213c751251e13ced397c --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/ColorField.vue @@ -0,0 +1,61 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="auto" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="color" + v-bind="$attrs" + v-on="$listeners" + placeholder="#AABBCC" + :rules="rules" + > + <template #prepend-inner> + <v-icon :color="color" v-bind="attrs" v-on="on"> mdi-circle </v-icon> + </template> + </v-text-field> + </template> + <v-color-picker v-if="menu" v-model="color" ref="picker"></v-color-picker> + </v-menu> +</template> + +<script> +export default { + name: "DateField", + extends: "v-text-field", + data() { + return { + menu: false, + rules: [ + (value) => + /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8}))?$/i.test(value) || + this.$t("forms.errors.invalid_color"), + ], + }; + }, + props: { + value: { + type: String, + default: undefined, + }, + }, + computed: { + color: { + get() { + return this.value; + }, + set(newValue) { + this.$emit("input", newValue); + }, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/DateField.vue b/aleksis/core/frontend/components/generic/forms/DateField.vue new file mode 100644 index 0000000000000000000000000000000000000000..68a8772fad15172b319711bb7ce9a4a6f5c79f7b --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/DateField.vue @@ -0,0 +1,109 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="auto" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="date" + v-bind="{ ...$attrs, ...attrs }" + @click="handleClick" + @focusin="handleFocusIn" + @focusout="handleFocusOut" + @click:clear="handleClickClear" + placeholder="YYYY-MM-DD" + @keydown.esc="menu = false" + @keydown.enter="menu = false" + :rules="rules" + ></v-text-field> + </template> + <v-date-picker + v-model="date" + ref="picker" + no-title + scrollable + :min="min" + :max="max" + :locale="$i18n.locale" + first-day-of-week="1" + show-adjacent-months + @input="menu = false" + ></v-date-picker> + </v-menu> +</template> + +<script> +export default { + name: "DateField", + extends: "v-text-field", + data() { + return { + menu: false, + innerDate: this.value, + openDueToFocus: true, + rules: [ + (value) => + !value || !!Date.parse(value) || this.$t("forms.errors.invalid_date"), + ], + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + min: { + type: String, + required: false, + default: undefined, + }, + max: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + date: { + get() { + return this.innerDate; + }, + set(value) { + this.innerDate = value; + this.$emit("input", value); + }, + }, + }, + methods: { + handleClickClear() { + if (this.clearable) { + this.date = null; + } + }, + handleClick() { + this.menu = true; + this.openDueToFocus = false; + }, + handleFocusIn() { + this.openDueToFocus = true; + this.menu = true; + }, + handleFocusOut() { + if (this.openDueToFocus) this.menu = false; + }, + }, + watch: { + value(newValue) { + this.innerDate = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..da046a69b36014968995db908d5190e5f027ca30 --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue @@ -0,0 +1,115 @@ +<script setup> +import DateField from "./DateField.vue"; +import TimeField from "./TimeField.vue"; +</script> + +<template> + <v-row> + <v-col cols="7"> + <date-field + v-model="date" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.date')" + :min="minDate" + :max="maxDate" + /> + </v-col> + <v-col cols="5"> + <time-field + v-model="time" + v-bind="{ ...$attrs }" + :label="$t('forms.date_time.time')" + :min="minTime" + :max="maxTime" + /> + </v-col> + </v-row> +</template> + +<script> +export default { + name: "DateTimeField", + data() { + return { + innerDateTime: this.value, + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + minDate: { + type: String, + required: false, + default: undefined, + }, + maxDate: { + type: String, + required: false, + default: undefined, + }, + minTime: { + type: String, + required: false, + default: undefined, + }, + maxTime: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + dateTime: { + get() { + return new Date(this.innerDateTime); + }, + set(value) { + this.innerDateTime = value; + this.$emit("input", value); + }, + }, + date: { + get() { + return this.dateTime.toISOString().split("T")[0]; + }, + set(value) { + let newDateTime = this.dateTime; + const [year, month, day] = value.split("-"); + + newDateTime.setFullYear(year); + newDateTime.setMonth(month - 1); + newDateTime.setDate(day); + + this.dateTime = newDateTime.toISOString(); + }, + }, + time: { + get() { + return `${("0" + this.dateTime.getHours()).slice(-2)}:${( + "0" + this.dateTime.getMinutes() + ).slice(-2)}`; + }, + set(value) { + let newDateTime = this.dateTime; + + const [hours, minutes] = value.split(":"); + + newDateTime.setHours(hours); + newDateTime.setMinutes(minutes); + + this.dateTime = newDateTime.toISOString(); + }, + }, + }, + watch: { + value(newValue) { + this.innerDateTime = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue new file mode 100644 index 0000000000000000000000000000000000000000..b32859065e17cda75c2697b577ad3b9bf09d3aba --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue @@ -0,0 +1,183 @@ +<template> + <v-autocomplete + v-bind="$attrs" + v-on="$listeners" + :items="items" + item-value="id" + :item-text="itemName" + class="fc-my-auto" + > + <template #append-outer> + <v-btn icon @click="menu = true"> + <v-icon>$plus</v-icon> + </v-btn> + + <slot + name="createComponent" + :attrs="{ + value: menu, + defaultItem: defaultItem, + gqlQuery: gqlQuery, + gqlCreateMutation: gqlCreateMutation, + gqlPatchMutation: gqlPatchMutation, + isCreate: true, + fields: fields, + getCreateData: getCreateData, + createItemI18nKey: createItemI18nKey, + }" + :on="{ + input: (i) => (menu = i), + cancel: () => (menu = false), + save: handleSave, + update: handleUpdate, + }" + > + <dialog-object-form + v-model="menu" + @cancel="menu = false" + @update="handleUpdate" + @save="handleSave" + @error="handleError" + :is-create="true" + :default-item="defaultItem" + :fields="fields" + :gql-query="gqlQuery" + :gql-patch-mutation="gqlPatchMutation" + :gql-create-mutation="gqlCreateMutation" + :create-item-i18n-key="createItemI18nKey" + :get-create-data="getCreateData" + > + <template + v-for="(_, name) in $scopedSlots" + :slot="name" + slot-scope="slotData" + > + <slot :name="name" v-bind="slotData" /> + </template> + </dialog-object-form> + </slot> + + <closable-snackbar :color="snackbarState" v-model="snackbar"> + {{ snackbarText }} + </closable-snackbar> + </template> + </v-autocomplete> +</template> + +<script> +import ClosableSnackbar from "../dialogs/ClosableSnackbar.vue"; +import DialogObjectForm from "../dialogs/DialogObjectForm.vue"; + +export default { + name: "ForeignKeyField", + components: { ClosableSnackbar, DialogObjectForm }, + extends: "v-autocomplete", + data() { + return { + menu: false, + snackbar: false, + snackbarState: "error", + snackbarText: "", + }; + }, + apollo: { + items() { + return { + query: this.gqlQuery, + fetchPolicy: "cache-first", + }; + }, + }, + methods: { + handleUpdate(store, createdObject) { + // Read the data from cache for query + const storedData = store.readQuery({ query: this.gqlQuery }); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const storedDataKey = Object.keys(storedData)[0]; + + // Add item to stored data + storedData[storedDataKey].push(createdObject); + + // Write data back to the cache + store.writeQuery({ query: this.gqlQuery, data: storedData }); + }, + handleSave(data) { + let newItem = + data.data[this.gqlCreateMutation.definitions[0].name.value].item; + let newValue = this.$attrs["return-object"] ? newItem : newItem.id; + let modelValue = + "multiple" in this.$attrs + ? Array.isArray(this.$attrs.value) + ? this.$attrs.value.concat(newValue) + : [newValue] + : newValue; + + this.$emit("input", modelValue); + }, + slotName(field) { + return field.value + ".field"; + }, + handleError(error) { + console.error(error); + if (error instanceof String) { + // error is a translation key or simply a string + this.snackbarText = this.$t(error); + } else if (error instanceof Object && error.message) { + this.snackbarText = error.message; + } else { + this.snackbarText = this.$t("graphql.snackbar_error_message"); + } + this.snackbarState = "error"; + this.snackbar = true; + }, + }, + props: { + defaultItem: { + type: Object, + required: true, + }, + fields: { + type: Array, + required: true, + }, + gqlQuery: { + type: Object, + required: true, + }, + gqlCreateMutation: { + type: Object, + required: true, + }, + gqlPatchMutation: { + type: Object, + required: true, + }, + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + itemName: { + type: String, + required: false, + default: "name", + }, + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + }, +}; +</script> + +<style scoped> +.fc-my-auto > :first-child { + margin-block: auto; +} +</style> diff --git a/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue new file mode 100644 index 0000000000000000000000000000000000000000..5deaab572366481ee22ff147028bfb32a79e00eb --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/PositiveSmallIntegerField.vue @@ -0,0 +1,56 @@ +<template> + <v-text-field + v-bind="$attrs" + v-on="on" + :rules="rules" + type="number" + inputmode="decimal" + ></v-text-field> +</template> + +<script> +export default { + name: "PositiveSmallIntegerField", + extends: "v-text-field", + methods: { + handleInput(event) { + let num = parseInt(event); + if (!isNaN(num) && num >= 0 && num <= 32767 && num % 1 === 0) { + this.$emit("input", parseInt(event)); + } + }, + }, + data() { + return { + rules: [ + (value) => + !value || + !isNaN(parseInt(value)) || + this.$t("forms.errors.not_a_number"), + (value) => + !value || + value % 1 === 0 || + this.$t("forms.errors.not_a_whole_number"), + (value) => + !value || + parseInt(value) >= 0 || + this.$t("forms.errors.number_too_small"), + (value) => + !value || + parseInt(value) <= 32767 || + this.$t("forms.errors.number_too_big"), + ], + }; + }, + computed: { + on() { + return { + ...this.$listeners, + input: this.handleInput, + }; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/TimeField.vue b/aleksis/core/frontend/components/generic/forms/TimeField.vue new file mode 100644 index 0000000000000000000000000000000000000000..bff97c880f8cf7cea92924ec49987ce9f78a22ef --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/TimeField.vue @@ -0,0 +1,114 @@ +<template> + <v-menu + ref="menu" + v-model="menu" + :close-on-content-click="false" + transition="scale-transition" + offset-y + min-width="290" + eager + > + <template #activator="{ on, attrs }"> + <v-text-field + v-model="time" + v-bind="{ ...$attrs, ...attrs }" + @click="handleClick" + @focusin="handleFocusIn" + @focusout="handleFocusOut" + @click:clear="handleClickClear" + placeholder="HH:MM[:SS]" + @keydown.esc="menu = false" + @keydown.enter="menu = false" + :prepend-icon="prependIcon" + :rules="rules" + ></v-text-field> + </template> + <v-time-picker + v-model="time" + ref="picker" + :min="min" + :max="max" + full-width + format="24hr" + @click:minute="menu = false" + ></v-time-picker> + </v-menu> +</template> + +<script> +export default { + name: "TimeField", + extends: "v-text-field", + data() { + return { + menu: false, + innerTime: this.value, + openDueToFocus: true, + rules: [ + (v) => + !v || + /^([01]\d|2[0-3]):([0-5]\d)(:([0-5]\d))?$/.test(v) || + this.$t("forms.errors.invalid_time"), + ], + }; + }, + props: { + value: { + type: String, + required: false, + default: undefined, + }, + min: { + type: String, + required: false, + default: undefined, + }, + max: { + type: String, + required: false, + default: undefined, + }, + prependIcon: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + time: { + get() { + return this.innerTime; + }, + set(value) { + this.innerTime = value; + this.$emit("input", value); + }, + }, + }, + methods: { + handleClickClear() { + if (this.clearable) { + this.time = null; + } + }, + handleClick() { + this.menu = true; + this.openDueToFocus = false; + }, + handleFocusIn() { + this.openDueToFocus = true; + this.menu = true; + }, + handleFocusOut() { + if (this.openDueToFocus) this.menu = false; + }, + }, + watch: { + value(newValue) { + this.innerTime = newValue; + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/generic/forms/WeekDayField.vue b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue new file mode 100644 index 0000000000000000000000000000000000000000..4e8c359343f799c43974ea923a27c8648bfa314c --- /dev/null +++ b/aleksis/core/frontend/components/generic/forms/WeekDayField.vue @@ -0,0 +1,68 @@ +<template> + <v-autocomplete + v-bind="$attrs" + v-on="$listeners" + :items="items" + :item-value="valueKey" + ></v-autocomplete> +</template> + +<script> +export default { + name: "WeekDayField", + extends: "v-autocomplete", + data() { + return { + items: [ + { + value: "A_0", + valueInt: 0, + text: this.$t("weekdays.A_0"), + }, + { + value: "A_1", + valueInt: 1, + text: this.$t("weekdays.A_1"), + }, + { + value: "A_2", + valueInt: 2, + text: this.$t("weekdays.A_2"), + }, + { + value: "A_3", + valueInt: 3, + text: this.$t("weekdays.A_3"), + }, + { + value: "A_4", + valueInt: 4, + text: this.$t("weekdays.A_4"), + }, + { + value: "A_5", + valueInt: 5, + text: this.$t("weekdays.A_5"), + }, + { + value: "A_6", + valueInt: 6, + text: this.$t("weekdays.A_6"), + }, + ], + }; + }, + props: { + returnInt: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + valueKey() { + return this.returnInt ? "valueInt" : "value"; + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/components/room/RoomInlineList.vue b/aleksis/core/frontend/components/room/RoomInlineList.vue new file mode 100644 index 0000000000000000000000000000000000000000..5d65067ee94a6b0c8c9dce3964200c315bc583c7 --- /dev/null +++ b/aleksis/core/frontend/components/room/RoomInlineList.vue @@ -0,0 +1,70 @@ +<script setup> +import InlineCRUDList from "../generic/InlineCRUDList.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="rooms.create_room" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #shortName.field="{ attrs, on, isCreate }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + required + :rules="shortNameRules" + ></v-text-field> + </div> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + rooms, + createRoom, + deleteRoom, + deleteRooms, + updateRooms, +} from "./room.graphql"; + +export default { + name: "RoomInlineList", + data() { + return { + headers: [ + { + text: this.$t("rooms.name"), + value: "name", + }, + { + text: this.$t("rooms.short_name"), + value: "shortName", + }, + ], + i18nKey: "rooms", + gqlQuery: rooms, + gqlCreateMutation: createRoom, + gqlPatchMutation: updateRooms, + gqlDeleteMutation: deleteRoom, + gqlDeleteMultipleMutation: deleteRooms, + defaultItem: { + name: "", + shortName: "", + }, + shortNameRules: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/room/room.graphql b/aleksis/core/frontend/components/room/room.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d8cd9138dd0fa5c4f3af7a7d47fe7b44ed94f6fd --- /dev/null +++ b/aleksis/core/frontend/components/room/room.graphql @@ -0,0 +1,45 @@ +query rooms($orderBy: [String], $filters: JSONString) { + items: rooms(orderBy: $orderBy, filters: $filters) { + id + name + shortName + canEdit + canDelete + } +} + +mutation createRoom($input: CreateRoomInput!) { + createRoom(input: $input) { + room { + id + name + shortName + canEdit + canDelete + } + } +} + +mutation deleteRoom($id: ID!) { + deleteRoom(id: $id) { + ok + } +} + +mutation deleteRooms($ids: [ID]!) { + deleteRooms(ids: $ids) { + deletionCount + } +} + +mutation updateRooms($input: [BatchPatchRoomInput]!) { + batchMutation: updateRooms(input: $input) { + items: rooms { + id + name + shortName + canEdit + canDelete + } + } +} diff --git a/aleksis/core/frontend/components/school_term/SchoolTermField.vue b/aleksis/core/frontend/components/school_term/SchoolTermField.vue new file mode 100644 index 0000000000000000000000000000000000000000..5a2ba1a752a69b56aed7d26db24f073d776238f6 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/SchoolTermField.vue @@ -0,0 +1,98 @@ +<script setup> +import DateField from "../generic/forms/DateField.vue"; +</script> + +<template> + <foreign-key-field + :gql-patch-mutation="{}" + :gql-create-mutation="gqlCreateMutation" + :gql-query="gqlQuery" + :fields="fields" + create-item-i18n-key="school_term.create_school_term" + :default-item="defaultItem" + v-bind="$attrs" + v-on="$listeners" + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on }"> + <div aria-required="true"> + <v-text-field + v-bind="attrs" + v-on="on" + required + :rules="required" + ></v-text-field> + </div> + </template> + + <template #dateStart="{ item }"> + {{ $d(new Date(item.dateStart), "short") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateStart.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :max="item ? item.dateEnd : undefined" + ></date-field> + </div> + </template> + + <template #dateEnd="{ item }"> + {{ $d(new Date(item.dateEnd), "short") }} + </template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + </foreign-key-field> +</template> + +<script> +import ForeignKeyField from "../generic/forms/ForeignKeyField.vue"; +import { createSchoolTerm, schoolTerms } from "./schoolTerm.graphql"; + +export default { + name: "SchoolTermField", + components: { ForeignKeyField }, + data() { + return { + gqlQuery: schoolTerms, + gqlCreateMutation: createSchoolTerm, + fields: [ + { + text: this.$t("school_term.name"), + value: "name", + }, + { + text: this.$t("school_term.date_start"), + value: "dateStart", + }, + { + text: this.$t("school_term.date_end"), + value: "dateEnd", + }, + ], + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b18c13f1a88196b9769262e3a5f3347a29d9ead4 --- /dev/null +++ b/aleksis/core/frontend/components/school_term/SchoolTermInlineList.vue @@ -0,0 +1,117 @@ +<script setup> +import InlineCRUDList from "../generic/InlineCRUDList.vue"; +import DateField from "../generic/forms/DateField.vue"; +</script> + +<template> + <inline-c-r-u-d-list + :headers="headers" + :i18n-key="i18nKey" + create-item-i18n-key="school_term.create_school_term" + :gql-query="gqlQuery" + :gql-create-mutation="gqlCreateMutation" + :gql-patch-mutation="gqlPatchMutation" + :gql-delete-mutation="gqlDeleteMutation" + :gql-delete-multiple-mutation="gqlDeleteMultipleMutation" + :default-item="defaultItem" + filter + > + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #name.field="{ attrs, on, item }"> + <div aria-required="true"> + <v-text-field v-bind="attrs" v-on="on" :rules="required"></v-text-field> + </div> + </template> + + <template #dateStart="{ item }">{{ + $d(new Date(item.dateStart), "short") + }}</template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateStart.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + :rules="required" + :max="item ? item.dateEnd : undefined" + ></date-field> + </div> + </template> + + <template #dateEnd="{ item }">{{ + $d(new Date(item.dateEnd), "short") + }}</template> + <!-- eslint-disable-next-line vue/valid-v-slot --> + <template #dateEnd.field="{ attrs, on, item }"> + <div aria-required="true"> + <date-field + v-bind="attrs" + v-on="on" + required + :rules="required" + :min="item ? item.dateStart : undefined" + ></date-field> + </div> + </template> + + <template #filters="{ attrs, on }"> + <date-field + v-bind="attrs('date_end__gte')" + v-on="on('date_end__gte')" + :label="$t('school_term.after')" + /> + + <date-field + v-bind="attrs('date_start__lte')" + v-on="on('date_start__lte')" + :label="$t('school_term.before')" + /> + </template> + </inline-c-r-u-d-list> +</template> + +<script> +import { + schoolTerms, + createSchoolTerm, + deleteSchoolTerm, + deleteSchoolTerms, + updateSchoolTerms, +} from "./schoolTerm.graphql"; + +export default { + name: "SchoolTermInlineList", + data() { + return { + headers: [ + { + text: this.$t("school_term.name"), + value: "name", + }, + { + text: this.$t("school_term.date_start"), + value: "dateStart", + }, + { + text: this.$t("school_term.date_end"), + value: "dateEnd", + }, + ], + i18nKey: "school_term", + gqlQuery: schoolTerms, + gqlCreateMutation: createSchoolTerm, + gqlPatchMutation: updateSchoolTerms, + gqlDeleteMutation: deleteSchoolTerm, + gqlDeleteMultipleMutation: deleteSchoolTerms, + defaultItem: { + name: "", + dateStart: "", + dateEnd: "", + }, + required: [(value) => !!value || this.$t("forms.errors.required")], + }; + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/frontend/components/school_term/schoolTerm.graphql b/aleksis/core/frontend/components/school_term/schoolTerm.graphql new file mode 100644 index 0000000000000000000000000000000000000000..bfd681b2eee97d781e4d489a9c82cdfc95d9bf5f --- /dev/null +++ b/aleksis/core/frontend/components/school_term/schoolTerm.graphql @@ -0,0 +1,48 @@ +query schoolTerms($orderBy: [String], $filters: JSONString) { + items: schoolTerms(orderBy: $orderBy, filters: $filters) { + id + name + dateStart + dateEnd + canEdit + canDelete + } +} + +mutation createSchoolTerm($input: CreateSchoolTermInput!) { + createSchoolTerm(input: $input) { + schoolTerm { + id + name + dateStart + dateEnd + canEdit + canDelete + } + } +} + +mutation deleteSchoolTerm($id: ID!) { + deleteSchoolTerm(id: $id) { + ok + } +} + +mutation deleteSchoolTerms($ids: [ID]!) { + deleteSchoolTerms(ids: $ids) { + deletionCount + } +} + +mutation updateSchoolTerms($input: [BatchPatchSchoolTermInput]!) { + batchMutation: updateSchoolTerms(input: $input) { + items: schoolTerms { + id + name + dateStart + dateEnd + canEdit + canDelete + } + } +} diff --git a/aleksis/core/frontend/messages/de.json b/aleksis/core/frontend/messages/de.json index 3fb6c19b89ea2dcdfd80a00c88496c4fabb89271..207db33feb3d4b49edb79ebae96230df241fa8fd 100644 --- a/aleksis/core/frontend/messages/de.json +++ b/aleksis/core/frontend/messages/de.json @@ -80,7 +80,9 @@ "edit": "Bearbeiten", "save": "Speichern", "search": "Suchen", - "stop_editing": "Bearbeiten beenden" + "stop_editing": "Bearbeiten beenden", + "filter": "Filter", + "clear_filters": "Filter zurücksetzen" }, "administration": { "backend_admin": { diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index 8be55bd566f50295291d61b392fdfc23a63ba2e8..ceda00c3994bccf4fc2c7b5e9cb83fe5ec2d00d9 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -72,15 +72,22 @@ } }, "actions": { + "title": "Actions", + "select_action": "Select Action", "back": "Back", "cancel": "Cancel", "close": "Close", "confirm_deletion": "Are you sure you want to delete this item?", + "confirm_deletion_multiple": "Are you sure you want to delete these items?", "delete": "Delete", "edit": "Edit", "save": "Save", "search": "Search", - "stop_editing": "Stop editing" + "stop_editing": "Stop editing", + "create": "Add", + "filter": "Filter", + "clear_filters": "Clear Filters", + "update": "Update" }, "administration": { "backend_admin": { @@ -133,9 +140,6 @@ "notice": "If the download does not start automatically, please click the button below.", "title": "Downloading PDF file ..." }, - "graphql": { - "snackbar_error_message": "There was an error retrieving the page data. Please try again." - }, "group": { "additional_field": { "menu_title": "Additional Fields", @@ -235,17 +239,66 @@ "school_term": { "menu_title": "School Terms", "title": "School Term", - "title_plural": "School Terms" + "title_plural": "School Terms", + "create_school_term": "Create School Term", + "date_start": "Start Date", + "date_end": "End Date", + "name": "Name", + "before": "Starts before", + "after": "Ends after" }, "service_worker": { "dismiss": "Dismiss", "new_version_available": "A new version of the app is available", "update": "Update" }, + "graphql": { + "snackbar_error_message": "There was an error retrieving the page data. Please try again.", + "snackbar_success_message": "The operation has been finished successfully." + }, "status": { "changes": "You have unsaved changes.", "error": "There has been an error while saving the latest changes.", "saved": "All changes are saved.", - "updating": "Changes are being synced." + "updating": "Changes are being synced.", + "object_create_success": "The object was created successfully.", + "object_edit_success": "The object was edited successfully.", + "object_delete_success": "The object was deleted successfully.", + "objects_delete_success": "The objects were deleted successfully." + }, + "rooms": { + "menu_title": "Rooms", + "title_plural": "Rooms", + "name": "Name", + "short_name": "Short Name", + "create_room": "Create new room" + }, + "forms": { + "errors": { + "required": "This field is required.", + "invalid_date": "This is not a valid date.", + "invalid_time": "This is not a valid time.", + "invalid_color": "This is not a valid color.", + "not_a_number": "Not a valid number", + "not_a_whole_number": "Please enter a whole number", + "number_too_small": "Please enter a bigger number.", + "number_too_big": "Please enter a smaller number." + }, + "date_time": { + "date": "Date", + "time": "Time" + } + }, + "weekdays": { + "A_0": "Monday", + "A_1": "Tuesday", + "A_2": "Wednesday", + "A_3": "Thursday", + "A_4": "Friday", + "A_5": "Saturday", + "A_6": "Sunday" + }, + "selection": { + "num_items_selected": "No items selected | 1 item selected | {n} items selected" } } diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index 2af742809548f5f2f70e9c13a1b7091e6d8145c5..84b3612f33510940b97dd57fec5370e49c768d4c 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -288,14 +288,23 @@ const routes = [ permission: "core.invite_rule", }, }, + { + path: "/rooms/", + component: () => import("./components/room/RoomInlineList.vue"), + name: "core.rooms", + meta: { + inMenu: true, + titleKey: "rooms.menu_title", + toolbarTitle: "rooms.menu_title", + icon: "mdi-floor-plan", + permission: "core.view_rooms_rule", + }, + }, ], }, { path: "#", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => import("./components/Parent.vue"), name: "core.administration", meta: { inMenu: true, @@ -344,10 +353,8 @@ const routes = [ }, { path: "/school_terms/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + component: () => + import("./components/school_term/SchoolTermInlineList.vue"), name: "core.school_terms", meta: { inMenu: true, @@ -356,22 +363,6 @@ const routes = [ permission: "core.view_schoolterm_rule", }, }, - { - path: "/school_terms/create/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.create_school_term", - }, - { - path: "/school_terms/:pk(\\d+)/", - component: () => import("./components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, - name: "core.editSchoolTerm", - }, { path: "/dashboard_widgets/", component: () => import("./components/LegacyBaseTemplate.vue"), diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py index 57370441b37a9db241a8a8a0bebbd8f8f00f5753..747f328ea82060b2b0148d9c928a498e018cefc9 100644 --- a/aleksis/core/management/commands/vite.py +++ b/aleksis/core/management/commands/vite.py @@ -1,6 +1,7 @@ import os from django.conf import settings +from django.core.management.base import CommandError from django_yarnpkg.management.base import BaseYarnCommand from django_yarnpkg.yarn import yarn_adapter @@ -26,4 +27,6 @@ class Command(BaseYarnCommand): yarn_adapter.install(settings.YARN_INSTALLED_APPS) # Run Vite build - run_vite([options["command"]]) + ret = run_vite([options["command"]]) + if ret != 0: + raise CommandError("yarn command failed", returncode=ret) diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index 7a549775f1730d57f83e6c3486644fd2c3bdda65..c79e756f509ea428359c95617e3e84d573985cf2 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -11,7 +11,23 @@ from django_cte import CTEManager, CTEQuerySet from polymorphic.managers import PolymorphicManager -class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager): +class AlekSISBaseManager(_CurrentSiteManager): + """Base manager for AlekSIS model customisation.""" + + def unmanaged(self) -> QuerySet: + """Get instances that are not managed by any particular app.""" + return super().get_queryset().filter(managed_by_app_label="") + + def managed_by_app(self, app_label: str) -> QuerySet: + """Get instances managed by a particular app.""" + return super().get_queryset().filter(managed_by_app_label=app_label) + + def get_queryset(self) -> QuerySet: + return self.unmanaged() + + +# FIXME rename this and other classes after removing sites framework +class CurrentSiteManagerWithoutMigrations(AlekSISBaseManager): """CurrentSiteManager for auto-generating managers just by query sets.""" use_in_migrations = False @@ -123,5 +139,5 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager): return super().get_queryset().filter(widget_id__in=dashboard_widget_pks) -class PolymorphicCurrentSiteManager(_CurrentSiteManager, PolymorphicManager): +class PolymorphicCurrentSiteManager(AlekSISBaseManager, PolymorphicManager): """Default manager for extensible, polymorphic models.""" diff --git a/aleksis/core/migrations/0001_initial.py b/aleksis/core/migrations/0001_initial.py index aa7398e14a68e208ce75400c04177b752b1be8d4..d36293fa5ac4e11b8c2ad9080e9f90e9c9b1bd7d 100644 --- a/aleksis/core/migrations/0001_initial.py +++ b/aleksis/core/migrations/0001_initial.py @@ -5,7 +5,7 @@ import aleksis.core.util.core_helpers import datetime from django.conf import settings import django.contrib.postgres.fields.jsonb -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import phonenumber_field.modelfields @@ -47,7 +47,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Addtitional fields for groups', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -80,7 +80,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Custom menus', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -99,7 +99,7 @@ class Migration(migrations.Migration): 'permissions': (('assign_child_groups_to_groups', 'Can assign child groups to groups'),), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -136,7 +136,7 @@ class Migration(migrations.Migration): 'permissions': (('view_address', 'Can view address'), ('view_contact_details', 'Can view contact details'), ('view_photo', 'Can view photo'), ('view_person_groups', 'Can view persons groups'), ('view_personal_details', 'Can view personal details')), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -149,7 +149,7 @@ class Migration(migrations.Migration): }, bases=('core.person',), managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -187,7 +187,7 @@ class Migration(migrations.Migration): 'abstract': False, }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -209,7 +209,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Notifications', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -226,7 +226,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Group types', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -295,7 +295,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Custom menu items', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -313,7 +313,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Announcement recipients', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.CreateModel( @@ -332,7 +332,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Activities', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0007_dashboard_widget_order.py b/aleksis/core/migrations/0007_dashboard_widget_order.py index 78c1c8bcb372a8fd9abfa6bd578e88c70e61d01f..1089112baad1430d0a12ae7c61fdf6f95f8d0164 100644 --- a/aleksis/core/migrations/0007_dashboard_widget_order.py +++ b/aleksis/core/migrations/0007_dashboard_widget_order.py @@ -1,6 +1,6 @@ # Generated by Django 3.1.4 on 2020-12-21 13:38 -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone diff --git a/aleksis/core/migrations/0008_data_check_result.py b/aleksis/core/migrations/0008_data_check_result.py index df2d51119513f8033da981c7cd12c6f9a93425b9..79fda72f47d6115cd609ea3a66e819ba3aedf3bd 100644 --- a/aleksis/core/migrations/0008_data_check_result.py +++ b/aleksis/core/migrations/0008_data_check_result.py @@ -1,6 +1,6 @@ # Generated by Django 3.1.3 on 2020-11-14 16:11 -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone @@ -58,6 +58,6 @@ class Migration(migrations.Migration): "verbose_name": "Data check result", "verbose_name_plural": "Data check results", }, - managers=[("objects", django.contrib.sites.managers.CurrentSiteManager()),], + managers=[("objects", aleksis.core.managers.AlekSISBaseManager()),], ), ] diff --git a/aleksis/core/migrations/0013_pdf_file.py b/aleksis/core/migrations/0013_pdf_file.py index 4ae06cffe58f3d90798c05e67b6ffd8843f14fef..2a2853eba761df25862b879d45f42416b586d847 100644 --- a/aleksis/core/migrations/0013_pdf_file.py +++ b/aleksis/core/migrations/0013_pdf_file.py @@ -1,7 +1,7 @@ # Generated by Django 3.2 on 2021-04-10 18:58 import aleksis.core.models -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion import django.utils.timezone @@ -40,7 +40,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'PDF files', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0016_taskuserassignment.py b/aleksis/core/migrations/0016_taskuserassignment.py index 59328cf6b702ddba0e37d121f5f5ce264c04d657..e413e3f0e6d965d2e56bb8f071786be56f0342e2 100644 --- a/aleksis/core/migrations/0016_taskuserassignment.py +++ b/aleksis/core/migrations/0016_taskuserassignment.py @@ -1,7 +1,7 @@ # Generated by Django 3.2 on 2021-05-09 10:55 from django.conf import settings -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models import django.db.models.deletion @@ -30,7 +30,7 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Task user assignments', }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), ] diff --git a/aleksis/core/migrations/0047_add_room_model.py b/aleksis/core/migrations/0047_add_room_model.py index 36464dd97e3757ca97d22c7c7957fbfa141f75b8..d0f5482601245e4fa04032a3ed948b180968b64f 100644 --- a/aleksis/core/migrations/0047_add_room_model.py +++ b/aleksis/core/migrations/0047_add_room_model.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.15 on 2022-11-20 14:20 from django.apps import apps -import django.contrib.sites.managers +import aleksis.core.managers from django.db import migrations, models from django.db.utils import ProgrammingError import django.db.models.deletion @@ -48,7 +48,7 @@ class Migration(migrations.Migration): 'permissions': (('view_room_timetable', 'Can view room timetable'),), }, managers=[ - ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ('objects', aleksis.core.managers.AlekSISBaseManager()), ], ), migrations.AddConstraint( diff --git a/aleksis/core/migrations/0050_managed_by_app_label.py b/aleksis/core/migrations/0050_managed_by_app_label.py new file mode 100644 index 0000000000000000000000000000000000000000..0de24ef5fe734d99c9c606b8b7b76a4167348190 --- /dev/null +++ b/aleksis/core/migrations/0050_managed_by_app_label.py @@ -0,0 +1,184 @@ +# Generated by Django 4.1.9 on 2023-07-06 21:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0002_alter_domain_unique"), + ("core", "0049_oauthapplication_post_logout_redirect_uris"), + ] + + operations = [ + migrations.AddField( + model_name="activity", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="additionalfield", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="announcement", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="announcementrecipient", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="custommenu", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="custommenuitem", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="dashboardwidgetorder", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="datacheckresult", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="group", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="grouptype", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="notification", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="pdffile", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="person", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="persongroupthrough", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="room", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="schoolterm", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + migrations.AddField( + model_name="taskuserassignment", + name="managed_by_app_label", + field=models.CharField( + blank=True, + editable=False, + max_length=255, + verbose_name="App label of app responsible for managing this instance", + ), + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index e734b40a8f7cf55b86e1f496cfb8e8cfe0099cfb..b55a700db5390895c2c827a9ec13931bc55dbac9 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -27,13 +27,14 @@ from dynamic_preferences.types import FilePreference from guardian.admin import GuardedModelAdmin from guardian.core import ObjectPermissionChecker from jsonstore.fields import IntegerField, JSONFieldMixin -from material.base import Layout, LayoutNode +from material.base import Fieldset, Layout, LayoutNode from polymorphic.base import PolymorphicModelBase from polymorphic.managers import PolymorphicManager from polymorphic.models import PolymorphicModel from rules.contrib.admin import ObjectPermissionsModelAdmin from aleksis.core.managers import ( + AlekSISBaseManager, CurrentSiteManagerWithoutMigrations, PolymorphicCurrentSiteManager, SchoolTermRelatedQuerySet, @@ -132,8 +133,16 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): site = models.ForeignKey( Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False, related_name="+" ) - objects = CurrentSiteManager() - objects_all_sites = models.Manager() + objects = AlekSISBaseManager() + # FIXME this is now broken, remove sites framework + objects_all = models.Manager() + + managed_by_app_label = models.CharField( + max_length=255, + verbose_name="App label of app responsible for managing this instance", + editable=False, + blank=True, + ) extra_permissions = [] @@ -381,7 +390,7 @@ class ExtensiblePolymorphicModel( """Model class for extensible, polymorphic models.""" objects = PolymorphicCurrentSiteManager() - objects_all_sites = PolymorphicManager() + objects_all = PolymorphicManager() class Meta: abstract = True @@ -449,6 +458,20 @@ class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass): cls.base_layout.append(node) cls.layout = Layout(*cls.base_layout) + visit_nodes = [node] + while visit_nodes: + current_node = visit_nodes.pop() + if isinstance(current_node, Fieldset): + visit_nodes += node.elements + else: + field_name = ( + current_node if isinstance(current_node, str) else current_node.field_name + ) + field = fields_for_model(cls._meta.model, [field_name])[field_name] + cls._meta.fields.append(field_name) + cls.base_fields[field_name] = field + setattr(cls, field_name, field) + class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin): """A base class for ModelAdmin combining django-guardian and rules.""" diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index a11fbb427d3c585ec145c83446dbab9cc7b856e4..ae137be9da07fb643e17e00fd2111892802cd24b 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -12,25 +12,41 @@ from haystack.utils.loading import UnifiedIndex from ..models import ( CustomMenu, DynamicRoute, + Group, Notification, OAuthAccessToken, PDFFile, Person, + Room, TaskUserAssignment, ) from ..util.apps import AppConfig from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person +from .base import FilterOrderList from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType from .custom_menu import CustomMenuType from .dynamic_routes import DynamicRouteType -from .group import GroupType # noqa +from .group import GroupType from .installed_apps import AppType from .message import MessageType from .notification import MarkNotificationReadMutation, NotificationType from .oauth import OAuthAccessTokenType, OAuthRevokeTokenMutation from .pdf import PDFFileType from .person import PersonMutation, PersonType -from .school_term import SchoolTermType # noqa +from .room import ( + RoomBatchDeleteMutation, + RoomBatchPatchMutation, + RoomCreateMutation, + RoomDeleteMutation, + RoomType, +) +from .school_term import ( + SchoolTermBatchDeleteMutation, + SchoolTermBatchPatchMutation, + SchoolTermCreateMutation, + SchoolTermDeleteMutation, + SchoolTermType, +) from .search import SearchResultType from .system_properties import SystemPropertiesType from .two_factor import TwoFactorType @@ -46,6 +62,8 @@ class Query(graphene.ObjectType): person_by_id = graphene.Field(PersonType, id=graphene.ID()) person_by_id_or_me = graphene.Field(PersonType, id=graphene.ID()) + groups = graphene.List(GroupType) + who_am_i = graphene.Field(UserType) system_properties = graphene.Field(SystemPropertiesType) @@ -70,6 +88,11 @@ class Query(graphene.ObjectType): oauth_access_tokens = graphene.List(OAuthAccessTokenType) + rooms = FilterOrderList(RoomType) + room_by_id = graphene.Field(RoomType, id=graphene.ID()) + + school_terms = FilterOrderList(SchoolTermType) + def resolve_ping(root, info, payload) -> str: return payload @@ -102,6 +125,10 @@ class Query(graphene.ObjectType): raise PermissionDenied() return person + @staticmethod + def resolve_groups(root, info, **kwargs): + return get_objects_for_user(info.context.user, "core.view_group", Group) + def resolve_who_am_i(root, info, **kwargs): return info.context.user @@ -172,6 +199,16 @@ class Query(graphene.ObjectType): def resolve_oauth_access_tokens(root, info, **kwargs): return OAuthAccessToken.objects.filter(user=info.context.user) + @staticmethod + def resolve_room_by_id(root, info, **kwargs): + pk = kwargs.get("id") + room_object = Room.objects.get(pk=pk) + + if not info.context.user.has_perm("core.view_room_rule", room_object): + raise PermissionDenied + + return room_object + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() @@ -182,6 +219,16 @@ class Mutation(graphene.ObjectType): revoke_oauth_token = OAuthRevokeTokenMutation.Field() + create_room = RoomCreateMutation.Field() + delete_room = RoomDeleteMutation.Field() + delete_rooms = RoomBatchDeleteMutation.Field() + update_rooms = RoomBatchPatchMutation.Field() + + create_school_term = SchoolTermCreateMutation.Field() + delete_school_term = SchoolTermDeleteMutation.Field() + delete_school_terms = SchoolTermBatchDeleteMutation.Field() + update_school_terms = SchoolTermBatchPatchMutation.Field() + def build_global_schema(): """Build global GraphQL schema from all apps.""" diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py index 36d4f0647bd25d56b58e14fc4b737a3893e20372..198217379e257e427aa1332ae7bce7734d139c18 100644 --- a/aleksis/core/schema/base.py +++ b/aleksis/core/schema/base.py @@ -1,8 +1,12 @@ +import json + +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.db.models import Model import graphene -from graphene_django import DjangoObjectType +from django_filters.filterset import FilterSet, filterset_factory +from graphene_django import DjangoListField, DjangoObjectType from ..util.core_helpers import queryset_rules_filter @@ -47,3 +51,151 @@ class DeleteMutation(graphene.Mutation): return cls(ok=True) else: raise PermissionDenied() + + +class PermissionsTypeMixin: + """Mixin for adding permissions to a Graphene type. + + To configure the names for the permissions or to do + different permission checking, override the respective + methods `resolve_can_edit` and `resolve_can_delete` + """ + + can_edit = graphene.Boolean() + can_delete = graphene.Boolean() + + @staticmethod + def resolve_can_edit(root: Model, info, **kwargs): + content_type = ContentType.objects.get_for_model(root) + perm = f"{content_type.app_label}.edit_{content_type.model}_rule" + return info.context.user.has_perm(perm, root) + + @staticmethod + def resolve_can_delete(root: Model, info, **kwargs): + content_type = ContentType.objects.get_for_model(root) + perm = f"{content_type.app_label}.delete_{content_type.model}_rule" + return info.context.user.has_perm(perm, root) + + +class PermissionBatchPatchMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class PermissionBatchDeleteMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class PermissionPatchMixin: + class Meta: + login_required = True + + @classmethod + def check_permissions(cls, root, info, input, id, obj): # noqa + if info.context.user.has_perms(cls._meta.permissions, root): + return + + raise PermissionDenied() + + +class DjangoFilterMixin: + """Filters a queryset with django filter.""" + + @classmethod + def get_filterset(cls): + meta = getattr(cls, "_meta", None) + + if not meta: + raise NotImplementedError(f"{cls.__name__} must implement class Meta for filtering.") + + if hasattr(meta, "filterset_class"): + filterset = getattr(meta, "filterset_class") + if filterset is not None: + return filterset + + model: Model = getattr(meta, "model") + fields = getattr(meta, "filter_fields", None) + + if not model: + raise NotImplementedError(f"{cls.__name__} must supply a model via the Meta class") + + if not fields: + # Django filter doesn't allow to filter without explicit fields + raise NotImplementedError( + f"{cls.__name__}.Meta must contain filter_fields or a filterset_class" + ) + + fs = filterset_factory(model=model, fields=fields) + + return fs + + @classmethod + def filter(cls, filters, queryset): # noqa + filterset_class = cls.get_filterset() + filterset: FilterSet = filterset_class(filters, queryset) + return filterset.qs + + +class FilterOrderList(DjangoListField): + """Generic filterable Field for lists of django models. + + After the models are filtered, they can be filtered again (e.g. + for permissions using the get_queryset method inside the + DjangoObjectType subclass. + """ + + def __init__(self, _type, *args, **kwargs): + kwargs.update(order_by=graphene.List(graphene.String)) + kwargs.update(filters=graphene.JSONString()) + super().__init__(_type, *args, **kwargs) + + @staticmethod + def list_resolver( + django_object_type, + resolver, + default_manager, + root, + info, + order_by=None, + filters=None, + **args, + ): + qs = DjangoListField.list_resolver( + django_object_type, resolver, default_manager, root, info, **args + ) + + if filters is not None: + if isinstance(filters, str): + filters = json.loads(filters) + + if isinstance(filters, dict) and len(filters.keys()) > 0: + for f_key, f_value in filters.items(): + if isinstance(f_value, list): + filters[f_key] = ",".join(map(str, f_value)) + + qs = django_object_type.filter(filters, qs) + + if order_by is not None: + if isinstance(order_by, str): + order_by = [order_by] + + qs = qs.order_by(*order_by) + + print(f"{filters=}") + + return qs diff --git a/aleksis/core/schema/room.py b/aleksis/core/schema/room.py index d4f76700793d2f7eededaac1b0361ebeff1a6a5a..575130971671cfa79743b985918b3ad1621da53f 100644 --- a/aleksis/core/schema/room.py +++ b/aleksis/core/schema/room.py @@ -1,9 +1,53 @@ from graphene_django import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) from ..models import Room +from .base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) -class RoomType(DjangoObjectType): +class RoomType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): class Meta: model = Room fields = ("id", "name", "short_name") + filter_fields = { + "id": ["exact", "lte", "gte"], + "name": ["icontains"], + "short_name": ["icontains"], + } + + @classmethod + def get_queryset(cls, queryset, info): + return queryset # FIXME filter this queryset based on permissions + + +class RoomCreateMutation(DjangoCreateMutation): + class Meta: + model = Room + permissions = ("core.create_room",) + + +class RoomDeleteMutation(DeleteMutation): + klass = Room + permission_required = "core.delete_room" + + +class RoomBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = Room + permissions = ("core.delete_room",) + + +class RoomBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): + class Meta: + model = Room + permissions = ("core.change_room",) diff --git a/aleksis/core/schema/school_term.py b/aleksis/core/schema/school_term.py index 1f6e504bc280689c9011a63da7d0af3b5ec53a06..798d1c62eb960d2c850a918345cbc03252a88820 100644 --- a/aleksis/core/schema/school_term.py +++ b/aleksis/core/schema/school_term.py @@ -1,8 +1,71 @@ +from django.core.exceptions import PermissionDenied, ValidationError +from django.utils.translation import gettext as _ + from graphene_django import DjangoObjectType +from graphene_django_cud.mutations import ( + DjangoBatchDeleteMutation, + DjangoBatchPatchMutation, + DjangoCreateMutation, +) from ..models import SchoolTerm +from .base import ( + DeleteMutation, + DjangoFilterMixin, + PermissionBatchDeleteMixin, + PermissionBatchPatchMixin, + PermissionsTypeMixin, +) + + +class SchoolTermType(PermissionsTypeMixin, DjangoFilterMixin, DjangoObjectType): + class Meta: + model = SchoolTerm + filter_fields = { + "name": ["icontains", "exact"], + "date_start": ["exact", "lt", "lte", "gt", "gte"], + "date_end": ["exact", "lt", "lte", "gt", "gte"], + } + + @classmethod + def get_queryset(cls, queryset, info, **kwargs): + if not info.context.user.has_perm("view_schoolterm_rule"): + raise PermissionDenied + + return queryset # FIXME filter this queryset based on permissions + + +class SchoolTermCreateMutation(DjangoCreateMutation): + class Meta: + model = SchoolTerm + permissions = ("core.create_school_term",) # FIXME + + @classmethod + def validate(cls, root, info, input): # noqa + date_start = input.get("date_start") + date_end = input.get("date_end") + if date_end < date_start: + raise ValidationError(_("The start date must be earlier than the end date.")) + + qs = SchoolTerm.objects.within_dates(date_start, date_end) + if qs.exists(): + raise ValidationError( + _("There is already a school term for this time or a part of this time.") + ) + + +class SchoolTermDeleteMutation(DeleteMutation): + klass = SchoolTerm + permission_required = "core.delete_school_term" # FIXME + + +class SchoolTermBatchDeleteMutation(PermissionBatchDeleteMixin, DjangoBatchDeleteMutation): + class Meta: + model = SchoolTerm + permissions = ("core.delete_school_term",) -class SchoolTermType(DjangoObjectType): +class SchoolTermBatchPatchMutation(PermissionBatchPatchMixin, DjangoBatchPatchMutation): class Meta: model = SchoolTerm + permissions = ("core.change_school_term",) # FIXME diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 656fcd30a69e245c02d7bf2a984517265c512da9..77cda4ee58ed24e8f3df6bab2196643023b3b5f7 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -935,12 +935,19 @@ LOGGING["root"] = { "handlers": ["console"], "level": _settings.get("logging.level", "WARNING"), } +# Configure global log Format +LOGGING["formatters"]["verbose"] = { + "format": "{asctime} {levelname} {name}[{process}]: {msg}", + "style": "{", +} # Add null handler for selective silencing LOGGING["handlers"]["null"] = {"class": "logging.NullHandler"} # Make console logging independent of DEBUG LOGGING["handlers"]["console"]["filters"].remove("require_debug_true") # Use root log level for console del LOGGING["handlers"]["console"]["level"] +# Use verbose log format for console +LOGGING["handlers"]["console"]["formatter"] = "verbose" # Disable exception mails if not desired if not _settings.get("logging.mail_admins", True): LOGGING["loggers"]["django"]["handlers"].remove("mail_admins") @@ -956,6 +963,9 @@ LOGGING["loggers"]["celery"] = { "level": _settings.get("logging.level", "WARNING"), "propagate": False, } +# Set Django log levels +LOGGING["loggers"]["django"]["level"] = _settings.get("logging.level", "WARNING") +LOGGING["loggers"]["django.server"]["level"] = _settings.get("logging.level", "WARNING") # Rules and permissions @@ -1040,6 +1050,9 @@ else: DEFAULT_FILE_STORAGE = "titofisto.TitofistoStorage" TITOFISTO_TIMEOUT = 10 * 60 +TITOFISTO_ENABLE_UPLOAD = True +TITOFISTO_UPLOAD_NAMESPACE = "__titofisto__/upload/" + SASS_PROCESSOR_STORAGE = DEFAULT_FILE_STORAGE SENTRY_ENABLED = _settings.get("health.sentry.enabled", False) diff --git a/aleksis/core/tables.py b/aleksis/core/tables.py index 5411cad4747bc8c5de011fcdd2abcde432842236..8963981bb22412b51abca69120b90dc3b0e18d72 100644 --- a/aleksis/core/tables.py +++ b/aleksis/core/tables.py @@ -10,24 +10,6 @@ from .models import Person from .util.core_helpers import get_site_preferences -class SchoolTermTable(tables.Table): - """Table to list persons.""" - - class Meta: - attrs = {"class": "highlight"} - - name = tables.LinkColumn("edit_school_term", args=[A("id")]) - date_start = tables.Column() - date_end = tables.Column() - edit = tables.LinkColumn( - "edit_school_term", - args=[A("id")], - text=_("Edit"), - attrs={"a": {"class": "btn-flat waves-effect waves-orange orange-text"}}, - verbose_name=_("Actions"), - ) - - class PersonsTable(tables.Table): """Table to list persons.""" diff --git a/aleksis/core/templates/core/school_term/create.html b/aleksis/core/templates/core/school_term/create.html deleted file mode 100644 index a3e049112caeaf84095dde68c7fd8d7a32f75602..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/create.html +++ /dev/null @@ -1,17 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} -{% load material_form i18n %} - -{% block browser_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Create school term{% endblocktrans %}{% endblock %} - -{% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - -{% endblock %} diff --git a/aleksis/core/templates/core/school_term/edit.html b/aleksis/core/templates/core/school_term/edit.html deleted file mode 100644 index aa1b1dcf5015e876d0b9aa316d25da31673f0a3f..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} -{% load material_form i18n %} - -{% block browser_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}Edit school term{% endblocktrans %}{% endblock %} - -{% block content %} - - <form method="post"> - {% csrf_token %} - {% form form=form %}{% endform %} - {% include "core/partials/save_button.html" %} - </form> - -{% endblock %} diff --git a/aleksis/core/templates/core/school_term/list.html b/aleksis/core/templates/core/school_term/list.html deleted file mode 100644 index 9df6af9727b868e13d43a75c42730b375ff6aa47..0000000000000000000000000000000000000000 --- a/aleksis/core/templates/core/school_term/list.html +++ /dev/null @@ -1,18 +0,0 @@ -{# -*- engine:django -*- #} - -{% extends "core/base.html" %} - -{% load i18n %} -{% load render_table from django_tables2 %} - -{% block browser_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %} -{% block page_title %}{% blocktrans %}School terms{% endblocktrans %}{% endblock %} - -{% block content %} - <a class="btn green waves-effect waves-light" href="{% url 'create_school_term' %}"> - <i class="material-icons left iconify" data-icon="mdi:add"></i> - {% trans "Create school term" %} - </a> - - {% render_table table %} -{% endblock %} diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 7a22c7bb20a9d3c2d0d0e5af6bece161ac5dbdfa..a79562f9b19a311abc6eac48760b18169885d1b6 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -96,17 +96,6 @@ urlpatterns = [ views.TwoFactorSetupView.as_view(), name="setup_two_factor_auth", ), - path("school_terms/", views.SchoolTermListView.as_view(), name="school_terms"), - path( - "school_terms/create/", - views.SchoolTermCreateView.as_view(), - name="create_school_term", - ), - path( - "school_terms/<int:pk>/", - views.SchoolTermEditView.as_view(), - name="edit_school_term", - ), path("persons/", views.persons, name="persons"), path( "person/", TemplateView.as_view(template_name="core/empty.html"), name="person" diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py index e370d6b125242381ab53f6c030c62d41c622d0ff..26f0752dc352cf0793ad7a9c7d70f95e015dd457 100644 --- a/aleksis/core/util/apps.py +++ b/aleksis/core/util/apps.py @@ -1,3 +1,4 @@ +import logging from importlib import metadata from typing import TYPE_CHECKING, Any, Optional, Sequence @@ -26,8 +27,11 @@ class AppConfig(django.apps.AppConfig): def __init_subclass__(cls): super().__init_subclass__() cls.default = True + cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__name__}") def ready(self): + self._logger.debug("Running app.ready") + super().ready() # Register default listeners @@ -36,9 +40,12 @@ class AppConfig(django.apps.AppConfig): preference_updated.connect(self.preference_updated) user_logged_in.connect(self.user_logged_in) user_logged_out.connect(self.user_logged_out) + self._logger.debug("Default signal handlers connected") # Getting an app ready means it should look at its config once + self._logger.debug("Force-loading preferences") self.preference_updated(self) + self._logger.debug("Preferences loaded") def get_distribution_name(self): """Get distribution name of application package.""" @@ -282,6 +289,8 @@ class AppConfig(django.apps.AppConfig): return {} def _maintain_default_data(self): + self._logger.debug("Maintaining default data for %s", self.get_name()) + from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType @@ -292,10 +301,19 @@ class AppConfig(django.apps.AppConfig): for model in self.get_models(): if hasattr(model, "maintain_default_data"): # Method implemented by each model object; can be left out + self._logger.info( + "Maintaining default data of %s in %s", model._meta.model_name, self.get_name() + ) model.maintain_default_data() if hasattr(model, "extra_permissions"): + self._logger.info( + "Maintaining extra permissions for %s in %s", + model._meta.model_name, + self.get_name(), + ) ct = ContentType.objects.get_for_model(model) for perm, verbose_name in model.extra_permissions: + self._logger.debug("Creating %s (%s)", perm, verbose_name) Permission.objects.get_or_create( codename=perm, content_type=ct, diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py index 5d343acc0533a2509077c0f0d15ca0bc8d1823f9..885ac39fd51833c55ce072cc8a425991cfea7db2 100644 --- a/aleksis/core/util/frontend_helpers.py +++ b/aleksis/core/util/frontend_helpers.py @@ -49,7 +49,7 @@ def write_vite_values(out_path: str) -> dict[str, Any]: json.dump(vite_values, out) -def run_vite(args: Optional[Sequence[str]] = None) -> None: +def run_vite(args: Optional[Sequence[str]] = None) -> int: args = list(args) if args else [] config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js") @@ -64,7 +64,7 @@ def run_vite(args: Optional[Sequence[str]] = None) -> None: log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent") args += ["-l", log_level] - yarn_adapter.call_yarn(["run", "vite"] + args) + return yarn_adapter.call_yarn(["run", "vite"] + args) def get_language_cookie(code: str) -> str: diff --git a/aleksis/core/views.py b/aleksis/core/views.py index c1de17adafc9bc33205a364b619b4c23c5b1e897..442a3d4d9e86f1ecba128e87f556f959dbfe06fb 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -93,7 +93,6 @@ from .forms import ( OAuthApplicationForm, PersonForm, PersonPreferenceForm, - SchoolTermForm, SelectPermissionForm, SitePreferenceForm, ) @@ -109,7 +108,6 @@ from .models import ( OAuthApplication, Person, PersonInvitation, - SchoolTerm, ) from .registries import ( group_preferences_registry, @@ -126,7 +124,6 @@ from .tables import ( GroupTypesTable, InvitationsTable, PersonsTable, - SchoolTermTable, UserGlobalPermissionTable, UserObjectPermissionTable, ) @@ -263,40 +260,6 @@ def index(request: HttpRequest) -> HttpResponse: return render(request, "core/index.html", context) -@method_decorator(pwa_cache, name="dispatch") -class SchoolTermListView(PermissionRequiredMixin, SingleTableView): - """Table of all school terms.""" - - model = SchoolTerm - table_class = SchoolTermTable - permission_required = "core.view_schoolterm_rule" - template_name = "core/school_term/list.html" - - -@method_decorator(never_cache, name="dispatch") -class SchoolTermCreateView(PermissionRequiredMixin, AdvancedCreateView): - """Create view for school terms.""" - - model = SchoolTerm - form_class = SchoolTermForm - permission_required = "core.add_schoolterm_rule" - template_name = "core/school_term/create.html" - success_url = reverse_lazy("school_terms") - success_message = _("The school term has been created.") - - -@method_decorator(never_cache, name="dispatch") -class SchoolTermEditView(PermissionRequiredMixin, AdvancedEditView): - """Edit view for school terms.""" - - model = SchoolTerm - form_class = SchoolTermForm - permission_required = "core.edit_schoolterm" - template_name = "core/school_term/edit.html" - success_url = reverse_lazy("school_terms") - success_message = _("The school term has been saved.") - - @pwa_cache @permission_required("core.view_persons_rule") def persons(request: HttpRequest) -> HttpResponse: diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index 287534f096c186bc428de742c4e5ff56b71e8eab..415d12aa00f6b8a2bf2747e47d5cc3e32fd7aaa3 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -201,7 +201,9 @@ export default defineConfig({ navigateFallback: "/", directoryIndex: null, navigateFallbackAllowlist: [ - new RegExp("^/(?!(django|admin|graphql|__icons__))[^.]*$"), + new RegExp( + "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + ), ], additionalManifestEntries: [ { url: "/", revision: crypto.randomUUID() }, @@ -215,7 +217,7 @@ export default defineConfig({ runtimeCaching: [ { urlPattern: new RegExp( - "^/(?!(django|admin|graphql|__icons__))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" ), handler: "CacheFirst", }, diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index 03ffc97b1e73b28561f2283338773672e4c41a73..b829cd2c92710efa6050f068319ef052e9b4138e 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -147,7 +147,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`: .. code-block:: shell pip3 install --break-system-packages aleksis aleksis-admin vite build - aleksis-admin collectstatic + aleksis-admin collectstatic --clear aleksis-admin migrate aleksis-admin createinitialrevisions diff --git a/docs/conf.py b/docs/conf.py index 919c2ec87fab03d7e7e6c7c982d695f76d559c43..4f14591b931a77ed87e0e2cc7af56b48805e820b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,9 +29,9 @@ copyright = "2019-2023 The AlekSIS team" author = "The AlekSIS Team" # The short X.Y version -version = "3.1" +version = "4.0" # The full version, including alpha/beta/rc tags -release = "3.1.2.dev0" +release = "4.0.0.dev0" # -- General configuration --------------------------------------------------- diff --git a/docs/dev/01_setup.rst b/docs/dev/01_setup.rst index f7f56f811bcd7b0bfca48fbf9793a026ff2a44bf..c98eb81925a370ad1e40ee8f000a70a8330892cc 100644 --- a/docs/dev/01_setup.rst +++ b/docs/dev/01_setup.rst @@ -7,7 +7,8 @@ by reading its documentation. Poetry makes a lot of stuff very easy, especially managing a virtual environment that contains AlekSIS and everything you need to run the -framework and selected apps. +framework and selected apps. The minimum supported version of Poetry +is 1.2.0. Also, `Yarn`_ is needed to resolve JavaScript dependencies. @@ -91,7 +92,7 @@ All three steps can be done with the ``poetry shell`` command and ALEKSIS_maintenance__debug=true ALEKSIS_database__password=aleksis poetry shell poetry run aleksis-admin vite build - poetry run aleksis-admin collectstatic + poetry run aleksis-admin collectstatic --clear poetry run aleksis-admin compilemessages poetry run aleksis-admin migrate poetry run aleksis-admin createinitialrevisions diff --git a/pyproject.toml b/pyproject.toml index 7280e24280a5b7e92cd94957965194ed491d1b1f..2a38377509bcb52ce50fc57917a19d209d863618 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "AlekSIS-Core" -version = "3.1.2.dev0" +version = "4.0.0.dev0" packages = [ { include = "aleksis" } ] @@ -28,12 +28,9 @@ authors = [ "Benedict Suska <benedict.suska@teckids.de>", "Lukas Weichelt <lukas.weichelt@teckids.de>" ] -maintainers = [ - "Jonathan Weth <dev@jonathanweth.de>", - "Dominik George <dominik.george@teckids.org>" -] +maintainers = ["Jonathan Weth <dev@jonathanweth.de>", "Dominik George <dominik.george@teckids.org>"] license = "EUPL-1.2-or-later" -homepage = "https://aleksis.org/" +homepage = "https://aleksis.org" repository = "https://edugit.org/AlekSIS/official/AlekSIS-Core" documentation = "https://aleksis.org/AlekSIS-Core/docs/html/" keywords = ["SIS", "education", "school", "digitisation", "school apps"] @@ -49,11 +46,14 @@ classifiers = [ "Typing :: Typed", ] +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" + [[tool.poetry.source]] name = "gitlab" url = "https://edugit.org/api/v4/projects/461/packages/pypi/simple" -secondary = true - +priority = "supplemental" [tool.poetry.dependencies] python = "^3.9" Django = "^4.1" @@ -113,10 +113,10 @@ ipython = "^8.0.0" django-oauth-toolkit = "^2.0.0" django-storages = {version = "^1.13.2", optional = true} boto3 = {version = "^1.26.142", optional = true} -django-cleanup = "^7.0.0" +django-cleanup = "^8.0.0" djangorestframework = "^3.12.4" Whoosh = "^2.7.4" -django-titofisto = "^0.2.0" +django-titofisto = "^1.0.0" haystack-redis = "^0.0.1" python-gnupg = "^0.5.0" sentry-sdk = {version = "^1.4.3", optional = true} @@ -124,27 +124,65 @@ django-cte = "^1.1.5" pycountry = "^22.0.0" django-iconify = "^0.3" customidenticon = "^0.1.5" -graphene-django = "^3.0.0" +graphene-django = ">=3.0.0, <=3.1.2" selenium = "^4.4.3" django-vite = "^2.0.2" +graphene-django-cud = "^0.10.0" +uwsgi = "^2.0.21" [tool.poetry.extras] ldap = ["django-auth-ldap"] s3 = ["boto3", "django-storages"] sentry = ["sentry-sdk"] -[tool.poetry.dev-dependencies] -aleksis-builddeps = {version=">=2023.1.dev0", allow-prereleases=true} -selenium = "<4.10.0" -uwsgi = "^2.0" - [tool.poetry.scripts] aleksis-admin = 'aleksis.core.__main__:aleksis_cmd' +[tool.poetry.group.dev.dependencies] +django-stubs = "^4.2" + +safety = "^2.3.5" + +flake8 = "^6.0.0" +flake8-django = "^1.0.0" +flake8-fixme = "^1.1.1" +flake8-mypy = "^17.8.0" +flake8-bandit = "^4.1.1" +flake8-builtins = "^2.0.0" +flake8-docstrings = "^1.5.0" +flake8-rst-docstrings = "^0.3.0" + +black = ">=21.0" +flake8-black = "^0.3.0" + +isort = "^5.0.0" +flake8-isort = "^6.0.0" + +curlylint = "^0.13.0" + +[tool.poetry.group.test.dependencies] +pytest = "^7.2" +pytest-django = "^4.1" +pytest-django-testing-postgresql = "^0.2" +pytest-cov = "^4.0.0" +pytest-sugar = "^0.9.2" +selenium = "<4.10.0" +freezegun = "^1.1.0" + +[tool.poetry.group.docs] +optional = true + +[tool.poetry.group.docs.dependencies] +sphinx = "^7.0" +sphinxcontrib-django = "^2.3.0" +sphinxcontrib-svg2pdfconverter = "^1.1.1" +sphinx-autodoc-typehints = "^1.7" +sphinx_material = "^0.0.35" + [tool.black] line-length = 100 exclude = "/migrations/" [build-system] -requires = ["poetry>=1.0"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"