diff --git a/.dev-js/.eslintrc.js b/.dev-js/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..40571d02f4c429d79d9ec85a4f37ba8c78f74b4c --- /dev/null +++ b/.dev-js/.eslintrc.js @@ -0,0 +1,252 @@ +module.exports = { + root: true, + overrides: [ + { + files: ["*.js", "*.vue"], + // parser: "vue-eslint-parser", + //processor: "@graphql-eslint/graphql", + extends: [ + "eslint:recommended", + "plugin:vue/strongly-recommended", + "plugin:@intlify/vue-i18n/recommended", + ], + rules: { + "no-unused-vars": "warn", + "vue/no-unused-vars": "off", + "vue/multi-word-component-names": "off", + "vue/attribute-hyphenation": "error", + "vue/v-slot-style": "error", + "@intlify/vue-i18n/key-format-style": [ + "error", + "snake_case", + { + splitByDots: false, + }, + ], + // "@intlify/vue-i18n/no-unused-keys": ["warn", {}], + "@intlify/vue-i18n/no-raw-text": [ + "error", + { + ignoreNodes: ["v-icon"], + ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$", + }, + ], + "@intlify/vue-i18n/no-deprecated-tc": "off", + // Fixes for prettier (avoid eslint-config-prettier) + // The following rules can be used in some cases. See the README for more + // information. (These are marked with `0` instead of `"off"` so that a + // script can distinguish them.) + curly: 0, + "lines-around-comment": 0, + "max-len": 0, + "no-confusing-arrow": 0, + "no-mixed-operators": 0, + "no-tabs": 0, + "no-unexpected-multiline": 0, + quotes: 0, + "@typescript-eslint/quotes": 0, + "babel/quotes": 0, + "vue/html-self-closing": 0, + "vue/max-len": 0, + + // The rest are rules that you never need to enable when using Prettier. + "array-bracket-newline": "off", + "array-bracket-spacing": "off", + "array-element-newline": "off", + "arrow-parens": "off", + "arrow-spacing": "off", + "block-spacing": "off", + "brace-style": "off", + "comma-dangle": "off", + "comma-spacing": "off", + "comma-style": "off", + "computed-property-spacing": "off", + "dot-location": "off", + "eol-last": "off", + "func-call-spacing": "off", + "function-call-argument-newline": "off", + "function-paren-newline": "off", + "generator-star": "off", + "generator-star-spacing": "off", + "implicit-arrow-linebreak": "off", + indent: "off", + "jsx-quotes": "off", + "key-spacing": "off", + "keyword-spacing": "off", + "linebreak-style": "off", + "multiline-ternary": "off", + "newline-per-chained-call": "off", + "new-parens": "off", + "no-arrow-condition": "off", + "no-comma-dangle": "off", + "no-extra-parens": "off", + "no-extra-semi": "off", + "no-floating-decimal": "off", + "no-mixed-spaces-and-tabs": "off", + "no-multi-spaces": "off", + "no-multiple-empty-lines": "off", + "no-reserved-keys": "off", + "no-space-before-semi": "off", + "no-trailing-spaces": "off", + "no-whitespace-before-property": "off", + "no-wrap-func": "off", + "nonblock-statement-body-position": "off", + "object-curly-newline": "off", + "object-curly-spacing": "off", + "object-property-newline": "off", + "one-var-declaration-per-line": "off", + "operator-linebreak": "off", + "padded-blocks": "off", + "quote-props": "off", + "rest-spread-spacing": "off", + semi: "off", + "semi-spacing": "off", + "semi-style": "off", + "space-after-function-name": "off", + "space-after-keywords": "off", + "space-before-blocks": "off", + "space-before-function-paren": "off", + "space-before-function-parentheses": "off", + "space-before-keywords": "off", + "space-in-brackets": "off", + "space-in-parens": "off", + "space-infix-ops": "off", + "space-return-throw-case": "off", + "space-unary-ops": "off", + "space-unary-word-ops": "off", + "switch-colon-spacing": "off", + "template-curly-spacing": "off", + "template-tag-spacing": "off", + "unicode-bom": "off", + "wrap-iife": "off", + "wrap-regex": "off", + "yield-star-spacing": "off", + "@babel/object-curly-spacing": "off", + "@babel/semi": "off", + "@typescript-eslint/brace-style": "off", + "@typescript-eslint/comma-dangle": "off", + "@typescript-eslint/comma-spacing": "off", + "@typescript-eslint/func-call-spacing": "off", + "@typescript-eslint/indent": "off", + "@typescript-eslint/keyword-spacing": "off", + "@typescript-eslint/member-delimiter-style": "off", + "@typescript-eslint/no-extra-parens": "off", + "@typescript-eslint/no-extra-semi": "off", + "@typescript-eslint/object-curly-spacing": "off", + "@typescript-eslint/semi": "off", + "@typescript-eslint/space-before-blocks": "off", + "@typescript-eslint/space-before-function-paren": "off", + "@typescript-eslint/space-infix-ops": "off", + "@typescript-eslint/type-annotation-spacing": "off", + "babel/object-curly-spacing": "off", + "babel/semi": "off", + "flowtype/boolean-style": "off", + "flowtype/delimiter-dangle": "off", + "flowtype/generic-spacing": "off", + "flowtype/object-type-curly-spacing": "off", + "flowtype/object-type-delimiter": "off", + "flowtype/quotes": "off", + "flowtype/semi": "off", + "flowtype/space-after-type-colon": "off", + "flowtype/space-before-generic-bracket": "off", + "flowtype/space-before-type-colon": "off", + "flowtype/union-intersection-spacing": "off", + "react/jsx-child-element-spacing": "off", + "react/jsx-closing-bracket-location": "off", + "react/jsx-closing-tag-location": "off", + "react/jsx-curly-newline": "off", + "react/jsx-curly-spacing": "off", + "react/jsx-equals-spacing": "off", + "react/jsx-first-prop-new-line": "off", + "react/jsx-indent": "off", + "react/jsx-indent-props": "off", + "react/jsx-max-props-per-line": "off", + "react/jsx-newline": "off", + "react/jsx-one-expression-per-line": "off", + "react/jsx-props-no-multi-spaces": "off", + "react/jsx-tag-spacing": "off", + "react/jsx-wrap-multilines": "off", + "standard/array-bracket-even-spacing": "off", + "standard/computed-property-even-spacing": "off", + "standard/object-curly-even-spacing": "off", + "unicorn/empty-brace-spaces": "off", + "unicorn/no-nested-ternary": "off", + "unicorn/number-literal-case": "off", + "vue/array-bracket-newline": "off", + "vue/array-bracket-spacing": "off", + "vue/arrow-spacing": "off", + "vue/block-spacing": "off", + "vue/block-tag-newline": "off", + "vue/brace-style": "off", + "vue/comma-dangle": "off", + "vue/comma-spacing": "off", + "vue/comma-style": "off", + "vue/dot-location": "off", + "vue/func-call-spacing": "off", + "vue/html-closing-bracket-newline": "off", + "vue/html-closing-bracket-spacing": "off", + "vue/html-end-tags": "off", + "vue/html-indent": "off", + "vue/html-quotes": "off", + "vue/key-spacing": "off", + "vue/keyword-spacing": "off", + "vue/max-attributes-per-line": "off", + "vue/multiline-html-element-content-newline": "off", + "vue/multiline-ternary": "off", + "vue/mustache-interpolation-spacing": "off", + "vue/no-extra-parens": "off", + "vue/no-multi-spaces": "off", + "vue/no-spaces-around-equal-signs-in-attribute": "off", + "vue/object-curly-newline": "off", + "vue/object-curly-spacing": "off", + "vue/object-property-newline": "off", + "vue/operator-linebreak": "off", + "vue/quote-props": "off", + "vue/script-indent": "off", + "vue/singleline-html-element-content-newline": "off", + "vue/space-in-parens": "off", + "vue/space-infix-ops": "off", + "vue/space-unary-ops": "off", + "vue/template-curly-spacing": "off", + }, + settings: { + "vue-i18n": { + localeDir: "./aleksis/core/frontend/messages/*.{json}", + messageSyntaxVersion: "^8.0.0", + }, + }, + env: { + es2021: true, + }, + parserOptions: { + ecmaVersion: "latest", + }, + }, + { + files: ["*.graphql"], + parser: "@graphql-eslint/eslint-plugin", + plugins: ["@graphql-eslint"], + extends: "plugin:@graphql-eslint/operations-recommended", + parserOptions: { + graphQLConfig: { + schema: "./schema.json", + documents: "../aleksis/**/*/frontend/**/*.graphql", + }, + }, + rules: { + "@graphql-eslint/no-anonymous-operations": "error", + "@graphql-eslint/no-duplicate-fields": "error", + "@graphql-eslint/naming-convention": [ + "error", + { + OperationDefinition: { + style: "camelCase", + forbiddenPrefixes: ["Query", "Mutation", "Subscription", "Get"], + forbiddenSuffixes: ["Query", "Mutation", "Subscription"], + }, + }, + ], + }, + }, + ], +}; diff --git a/.dev-js/package.json b/.dev-js/package.json index baf299f86c8dedc316ac23108629472442053dd9..298e6c130173b7870174169d5968bf58e2a41050 100644 --- a/.dev-js/package.json +++ b/.dev-js/package.json @@ -2,10 +2,12 @@ "name": "aleksis-builddeps", "version": "1.0.0", "dependencies": { + "@graphql-eslint/eslint-plugin": "^4.3.0", "@intlify/eslint-plugin-vue-i18n": "^3.0.0", "eslint": "^8.26.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-vue": "^9.7.0", + "graphql": "^16.10.0", "prettier": "^3.4.0", "stylelint": "^16.0.0", "stylelint-config-prettier": "^9.0.3", diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 60317f4987d885fc380d731c6dd8792325342f44..0000000000000000000000000000000000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,215 +0,0 @@ -module.exports = { - extends: [ - "eslint:recommended", - "plugin:vue/strongly-recommended", - "plugin:@intlify/vue-i18n/recommended", - ], - rules: { - "no-unused-vars": "warn", - "vue/no-unused-vars": "off", - "vue/multi-word-component-names": "off", - "@intlify/vue-i18n/key-format-style": [ - "error", - "snake_case", - { - splitByDots: false, - }, - ], - // "@intlify/vue-i18n/no-unused-keys": ["warn", {}], - "@intlify/vue-i18n/no-raw-text": [ - "error", - { - ignoreNodes: ["v-icon"], - ignorePattern: "^[-–—·#:()\\[\\]&\\.\\s]+$", - }, - ], - // Fixes for prettier (avoid eslint-config-prettier) - // The following rules can be used in some cases. See the README for more - // information. (These are marked with `0` instead of `"off"` so that a - // script can distinguish them.) - curly: 0, - "lines-around-comment": 0, - "max-len": 0, - "no-confusing-arrow": 0, - "no-mixed-operators": 0, - "no-tabs": 0, - "no-unexpected-multiline": 0, - quotes: 0, - "@typescript-eslint/quotes": 0, - "babel/quotes": 0, - "vue/html-self-closing": 0, - "vue/max-len": 0, - - // The rest are rules that you never need to enable when using Prettier. - "array-bracket-newline": "off", - "array-bracket-spacing": "off", - "array-element-newline": "off", - "arrow-parens": "off", - "arrow-spacing": "off", - "block-spacing": "off", - "brace-style": "off", - "comma-dangle": "off", - "comma-spacing": "off", - "comma-style": "off", - "computed-property-spacing": "off", - "dot-location": "off", - "eol-last": "off", - "func-call-spacing": "off", - "function-call-argument-newline": "off", - "function-paren-newline": "off", - "generator-star": "off", - "generator-star-spacing": "off", - "implicit-arrow-linebreak": "off", - indent: "off", - "jsx-quotes": "off", - "key-spacing": "off", - "keyword-spacing": "off", - "linebreak-style": "off", - "multiline-ternary": "off", - "newline-per-chained-call": "off", - "new-parens": "off", - "no-arrow-condition": "off", - "no-comma-dangle": "off", - "no-extra-parens": "off", - "no-extra-semi": "off", - "no-floating-decimal": "off", - "no-mixed-spaces-and-tabs": "off", - "no-multi-spaces": "off", - "no-multiple-empty-lines": "off", - "no-reserved-keys": "off", - "no-space-before-semi": "off", - "no-trailing-spaces": "off", - "no-whitespace-before-property": "off", - "no-wrap-func": "off", - "nonblock-statement-body-position": "off", - "object-curly-newline": "off", - "object-curly-spacing": "off", - "object-property-newline": "off", - "one-var-declaration-per-line": "off", - "operator-linebreak": "off", - "padded-blocks": "off", - "quote-props": "off", - "rest-spread-spacing": "off", - semi: "off", - "semi-spacing": "off", - "semi-style": "off", - "space-after-function-name": "off", - "space-after-keywords": "off", - "space-before-blocks": "off", - "space-before-function-paren": "off", - "space-before-function-parentheses": "off", - "space-before-keywords": "off", - "space-in-brackets": "off", - "space-in-parens": "off", - "space-infix-ops": "off", - "space-return-throw-case": "off", - "space-unary-ops": "off", - "space-unary-word-ops": "off", - "switch-colon-spacing": "off", - "template-curly-spacing": "off", - "template-tag-spacing": "off", - "unicode-bom": "off", - "wrap-iife": "off", - "wrap-regex": "off", - "yield-star-spacing": "off", - "@babel/object-curly-spacing": "off", - "@babel/semi": "off", - "@typescript-eslint/brace-style": "off", - "@typescript-eslint/comma-dangle": "off", - "@typescript-eslint/comma-spacing": "off", - "@typescript-eslint/func-call-spacing": "off", - "@typescript-eslint/indent": "off", - "@typescript-eslint/keyword-spacing": "off", - "@typescript-eslint/member-delimiter-style": "off", - "@typescript-eslint/no-extra-parens": "off", - "@typescript-eslint/no-extra-semi": "off", - "@typescript-eslint/object-curly-spacing": "off", - "@typescript-eslint/semi": "off", - "@typescript-eslint/space-before-blocks": "off", - "@typescript-eslint/space-before-function-paren": "off", - "@typescript-eslint/space-infix-ops": "off", - "@typescript-eslint/type-annotation-spacing": "off", - "babel/object-curly-spacing": "off", - "babel/semi": "off", - "flowtype/boolean-style": "off", - "flowtype/delimiter-dangle": "off", - "flowtype/generic-spacing": "off", - "flowtype/object-type-curly-spacing": "off", - "flowtype/object-type-delimiter": "off", - "flowtype/quotes": "off", - "flowtype/semi": "off", - "flowtype/space-after-type-colon": "off", - "flowtype/space-before-generic-bracket": "off", - "flowtype/space-before-type-colon": "off", - "flowtype/union-intersection-spacing": "off", - "react/jsx-child-element-spacing": "off", - "react/jsx-closing-bracket-location": "off", - "react/jsx-closing-tag-location": "off", - "react/jsx-curly-newline": "off", - "react/jsx-curly-spacing": "off", - "react/jsx-equals-spacing": "off", - "react/jsx-first-prop-new-line": "off", - "react/jsx-indent": "off", - "react/jsx-indent-props": "off", - "react/jsx-max-props-per-line": "off", - "react/jsx-newline": "off", - "react/jsx-one-expression-per-line": "off", - "react/jsx-props-no-multi-spaces": "off", - "react/jsx-tag-spacing": "off", - "react/jsx-wrap-multilines": "off", - "standard/array-bracket-even-spacing": "off", - "standard/computed-property-even-spacing": "off", - "standard/object-curly-even-spacing": "off", - "unicorn/empty-brace-spaces": "off", - "unicorn/no-nested-ternary": "off", - "unicorn/number-literal-case": "off", - "vue/array-bracket-newline": "off", - "vue/array-bracket-spacing": "off", - "vue/arrow-spacing": "off", - "vue/block-spacing": "off", - "vue/block-tag-newline": "off", - "vue/brace-style": "off", - "vue/comma-dangle": "off", - "vue/comma-spacing": "off", - "vue/comma-style": "off", - "vue/dot-location": "off", - "vue/func-call-spacing": "off", - "vue/html-closing-bracket-newline": "off", - "vue/html-closing-bracket-spacing": "off", - "vue/html-end-tags": "off", - "vue/html-indent": "off", - "vue/html-quotes": "off", - "vue/key-spacing": "off", - "vue/keyword-spacing": "off", - "vue/max-attributes-per-line": "off", - "vue/multiline-html-element-content-newline": "off", - "vue/multiline-ternary": "off", - "vue/mustache-interpolation-spacing": "off", - "vue/no-extra-parens": "off", - "vue/no-multi-spaces": "off", - "vue/no-spaces-around-equal-signs-in-attribute": "off", - "vue/object-curly-newline": "off", - "vue/object-curly-spacing": "off", - "vue/object-property-newline": "off", - "vue/operator-linebreak": "off", - "vue/quote-props": "off", - "vue/script-indent": "off", - "vue/singleline-html-element-content-newline": "off", - "vue/space-in-parens": "off", - "vue/space-infix-ops": "off", - "vue/space-unary-ops": "off", - "vue/template-curly-spacing": "off", - }, - settings: { - "vue-i18n": { - localeDir: "./aleksis/core/frontend/messages/*.{json}", - messageSyntaxVersion: "^8.0.0", - }, - }, - env: { - es2021: true, - }, - parserOptions: { - ecmaVersion: "latest", - }, -}; diff --git a/.gitignore b/.gitignore index fc620d9045075bdec195fc0bd15702daa2b3e408..613db10966de0e5b57a87f57c6fd586925ade0b7 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,8 @@ docs/_build/ .dev-js/.yarn .dev-js/.pnp.cjs .dev-js/.pnp.loader.mjs +.dev-js/.yarnrc.yml +.dev-js/schema.json # Lock files poetry.lock diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cd7552d1fde2b2fea58688d0b55e5dd1e1c41203..66ae876f1e2d31f45635ed1409c0ebd54b7d4d47 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,6 +31,9 @@ files accordingly (see docs for further instructions). As legacy pages are no longer themed, you should update them to the new frontend as soon as possible. +To make setting names consistent, the setting `auth.login.registration.unique_email` +was renamed to `auth.registration.unique_email`. + Added ~~~~~ @@ -77,6 +80,7 @@ Changed * Replace all mentions of Redis with Valkey where possible * Show avatars of groups in all places. * Use new auth rate limiting settings +* Setting `auth.login.registration.unique_email` was renamed to `auth.registration.unique_email` * Bump Python version to 3.10 Fixed diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue index d48aff1a9c50adf60e531bb4f8f30c1ceeedff9b..ee6bcc954c4f35ff39ba31ac8680b26541eab51d 100644 --- a/aleksis/core/frontend/components/app/App.vue +++ b/aleksis/core/frontend/components/app/App.vue @@ -295,6 +295,7 @@ export default { pollInterval: 30000, result({ data }) { if (data && data.whoAmI) { + this.$root.whoAmI = data.whoAmI; this.$root.permissions = data.whoAmI.permissions; } }, diff --git a/aleksis/core/frontend/components/app/customMenu.graphql b/aleksis/core/frontend/components/app/customMenu.graphql index 0d62c9dba32f931233ccf283dab89b3191a2ff8a..20fed201f1270c47a9afadb9fa96ddb73f9ca6fc 100644 --- a/aleksis/core/frontend/components/app/customMenu.graphql +++ b/aleksis/core/frontend/components/app/customMenu.graphql @@ -1,7 +1,9 @@ query customMenu($name: String!) { customMenuByName(name: $name) { + id name items { + id name url icon diff --git a/aleksis/core/frontend/components/app/whoAmI.graphql b/aleksis/core/frontend/components/app/whoAmI.graphql index 159e4c088e1ba1327130711f9fac9e8d1645b607..1af27f4aeb41dcbe1e2722895de0019806762e45 100644 --- a/aleksis/core/frontend/components/app/whoAmI.graphql +++ b/aleksis/core/frontend/components/app/whoAmI.graphql @@ -6,6 +6,7 @@ query whoAmI($permissions: [String]!) { isAnonymous isImpersonate person { + id photo { url } diff --git a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql index 9c1063d0686553cb07beba2beff1ad5b15044455..8a48c410a6dfec246b755688591fbee047aae170 100644 --- a/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql +++ b/aleksis/core/frontend/components/authorized_oauth_applications/revokeOauthToken.graphql @@ -1,5 +1,5 @@ mutation revokeOauthTokens($ids: [ID]!) { revokeOauthTokens(ids: $ids) { - revokationCount + ok } } diff --git a/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue b/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue index 33bd0b1286c331e9f9a39fd102ae7b2485b62c3e..2ccff5dca6f3252cd89dc7721dc1e5f70aeed6ed 100644 --- a/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue +++ b/aleksis/core/frontend/components/calendar/personal_event/PersonalEventDialog.vue @@ -60,7 +60,7 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #datetimeStart.field="{ attrs, on }"> + <template #datetimeStart.field="{ attrs, on, item }"> <v-slide-y-transition appear> <div aria-required="true"> <date-time-field @@ -69,13 +69,14 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v v-bind="attrs" v-on="on" required + :max="item.datetimeEnd" /> </div> </v-slide-y-transition> </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #datetimeEnd.field="{ attrs, on }"> + <template #datetimeEnd.field="{ attrs, on, item }"> <v-slide-y-transition appear> <div aria-required="true"> <date-time-field @@ -84,6 +85,9 @@ import CollapseTriggerButton from "../../generic/buttons/CollapseTriggerButton.v v-bind="attrs" v-on="on" required + :min=" + $parseISODate(item.datetimeStart).plus({ minutes: 1 }).toISO() + " /> </div> </v-slide-y-transition> @@ -255,8 +259,13 @@ export default { datetimeEnd: item.fullDay ? undefined : item.datetimeEnd, dateStart: item.fullDay ? item.dateStart : undefined, dateEnd: item.fullDay ? item.dateEnd : undefined, - recurrences: item.recurring === false ? "" : item.recurrences, - timezone: DateTime.local().zoneName, + ...(item.recurring + ? { + // Add clients timezone only if item is recurring + timezone: DateTime.local().zoneName, + recurrences: item.recurrences, + } + : {}), persons: this.checkPermission( "core.create_personal_event_with_invitations_rule", ) diff --git a/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql b/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql index 695c0719079a86ad3c428f526490a1d2c1b6ca76..a22bc0a465f0b2a64610a17a6f5693c6d509ab89 100644 --- a/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql +++ b/aleksis/core/frontend/components/calendar/personal_event/personalEvent.graphql @@ -1,25 +1,30 @@ +fragment personalEventFields on PersonalEventType { + id + title + description + location + + datetimeStart + datetimeEnd + dateStart + dateEnd + timezone + recurrences + + persons { + id + fullName + } + groups { + id + shortName + } +} + mutation createPersonalEvents($input: [BatchCreatePersonalEventInput]!) { createPersonalEvents(input: $input) { items: personalEvents { - id - title - description - location - - datetimeStart - datetimeEnd - dateStart - dateEnd - recurrences - - persons { - id - fullName - } - groups { - id - shortName - } + ...personalEventFields } } } @@ -33,25 +38,7 @@ mutation deletePersonalEvents($ids: [ID]!) { mutation updatePersonalEvents($input: [BatchPatchPersonalEventInput]!) { updatePersonalEvents(input: $input) { items: personalEvents { - id - title - description - location - - datetimeStart - datetimeEnd - dateStart - dateEnd - recurrences - - persons { - id - fullName - } - groups { - id - shortName - } + ...personalEventFields } } } diff --git a/aleksis/core/frontend/components/generic/crud/FullscreenDialogObjectForm.vue b/aleksis/core/frontend/components/generic/crud/FullscreenDialogObjectForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..cf867830cb5a2a055a5bd551bb47351960d5d8d4 --- /dev/null +++ b/aleksis/core/frontend/components/generic/crud/FullscreenDialogObjectForm.vue @@ -0,0 +1,68 @@ +<script> +import SaveButton from "../buttons/SaveButton.vue"; +import ObjectForm from "./ObjectForm.vue"; +import CancelButton from "../buttons/CancelButton.vue"; +import objectFormPropsMixin from "../../../mixins/objectFormPropsMixin"; +import loadingMixin from "../../../mixins/loadingMixin"; +import FullscreenDialogPage from "../dialogs/FullscreenDialogPage.vue"; + +export default { + name: "FullscreenDialogObjectForm", + components: { FullscreenDialogPage, CancelButton, ObjectForm, SaveButton }, + mixins: [objectFormPropsMixin, loadingMixin], + props: { + successRedirectUrl: { + type: [String, Object], + default: null, + }, + fallbackUrl: { + type: [Object, String], + default: null, + }, + }, + methods: { + cancel() { + this.$backOrElse(this.fallbackUrl); + }, + save() { + this.$router.push(this.successRedirectUrl); + }, + }, + data() { + return { + valid: false, + }; + }, + mounted() { + this.$setToolBarTitle(this?.$refs?.form?.title); + }, +}; +</script> + +<template> + <fullscreen-dialog-page v-bind="$attrs"> + <object-form + ref="form" + v-bind="objectFormProps" + v-on="$listeners" + :valid.sync="valid" + @loading="handleLoading" + @save="save" + @cancel="cancel" + > + <template v-for="(_, slot) of $scopedSlots" #[slot]="scope" + ><slot :name="slot" v-bind="scope" + /></template> + </object-form> + + <template #actions> + <v-spacer /> + <cancel-button @click="cancel" :disabled="loading" /> + <save-button + @click="$refs?.form.submit()" + :loading="loading" + :disabled="!valid" + /> + </template> + </fullscreen-dialog-page> +</template> diff --git a/aleksis/core/frontend/components/generic/crud/ObjectForm.vue b/aleksis/core/frontend/components/generic/crud/ObjectForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..ebd801b1028f61019e22d8eb33a5b09e08a35580 --- /dev/null +++ b/aleksis/core/frontend/components/generic/crud/ObjectForm.vue @@ -0,0 +1,42 @@ +<script> +import objectFormMixin from "../../../mixins/objectFormMixin"; + +export default { + name: "ObjectForm", + mixins: [objectFormMixin], +}; +</script> + +<template> + <form @submit.stop.prevent="submit"> + <v-form v-model="valid"> + <v-container> + <v-row> + <v-col + cols="12" + :sm="field.cols || 6" + v-for="field in fields" + :key="field.value" + > + <!-- @slot Per field slot. Use #field-value.field to customize individual fields. --> + <slot + :label="field.text" + :name="field.value + '.field'" + :attrs="buildAttrs(itemModel, field)" + :on="buildOn(dynamicSetter(itemModel, field.value))" + :is-create="isCreate" + :item="itemModel" + :setter="buildExternalSetter(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> + </form> +</template> diff --git a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue index 020c59f9e691503c5530dcd69eda858fee48d4a8..79ed5e397986ae4b27fa4767433997664e6f6024 100644 --- a/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue +++ b/aleksis/core/frontend/components/generic/dialogs/DialogObjectForm.vue @@ -12,48 +12,32 @@ <template #title> <!-- @slot The title of the dialog-object-form --> <slot name="title"> - <span class="text-h5">{{ - isCreate ? $t(createItemI18nKey) : $t(editItemI18nKey) - }}</span> + <span class="text-h5"> + {{ $refs?.form?.title }} + </span> </slot> </template> <template #content> - <v-form v-model="valid"> - <v-container> - <v-row> - <v-col - cols="12" - :sm="field.cols || 6" - v-for="field in fields" - :key="field.value" - > - <!-- @slot Per field slot. Use #field-value.field to customize individual fields. --> - <slot - :label="field.text" - :name="field.value + '.field'" - :attrs="buildAttrs(itemModel, field)" - :on="buildOn(dynamicSetter(itemModel, field.value))" - :is-create="isCreate" - :item="itemModel" - :setter="buildExternalSetter(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> + <object-form + ref="form" + v-bind="objectFormProps" + v-on="$listeners" + :valid.sync="valid" + @loading="handleLoading" + @save="dialog = false" + @cancel="dialog = false" + > + <template v-for="(_, slot) of $scopedSlots" #[slot]="scope" + ><slot :name="slot" v-bind="scope" + /></template> + </object-form> </template> <template #actions> - <cancel-button @click="cancel" :disabled="loading" /> + <cancel-button @click="dialog = false" :disabled="loading" /> <save-button - @click="createOrPatch([itemModel])" + @click="$refs.form.submit()" :loading="loading" :disabled="!valid" /> @@ -62,10 +46,13 @@ </template> <script> -import createOrPatchMixin from "../../../mixins/createOrPatchMixin.js"; import SaveButton from "../buttons/SaveButton.vue"; import CancelButton from "../buttons/CancelButton.vue"; import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; +import openableDialogMixin from "../../../mixins/openableDialogMixin"; +import objectFormPropsMixin from "../../../mixins/objectFormPropsMixin"; +import ObjectForm from "../crud/ObjectForm.vue"; +import loadingMixin from "../../../mixins/loadingMixin"; /** * This component provides a form for creating or updating objects via graphQL (createOrPatchMixin) @@ -73,100 +60,13 @@ import MobileFullscreenDialog from "./MobileFullscreenDialog.vue"; export default { name: "DialogObjectForm", components: { + ObjectForm, CancelButton, SaveButton, MobileFullscreenDialog, }, - mixins: [createOrPatchMixin], - props: { - /** - * Dialog state (open or closed) - * @model - * @values true,false - */ - value: { - type: Boolean, - default: false, - }, - /** - * Title if isCreate is true - */ - createItemI18nKey: { - type: String, - required: false, - default: "actions.create", - }, - /** - * Title if isCreate is false - */ - editItemI18nKey: { - type: String, - required: false, - default: "actions.edit", - }, - /** - * SuccessMessage if isCreate is true - */ - createSuccessMessageI18nKey: { - type: String, - required: false, - default: "status.object_create_success", - }, - /** - * SuccessMessage if isCreate is false - */ - editSuccessMessageI18nKey: { - type: String, - required: false, - default: "status.object_edit_success", - }, - /** - * Fields in dialog-object-form - * - * @values list of field objects - * @example [{text: "Field text", value: "Field value name"} ...] - */ - fields: { - type: Array, - required: true, - }, - /** - * Default item used for creation if isCreate is true - */ - defaultItem: { - type: Object, - required: false, - default: null, - }, - /** - * Item offered for editing if isCreate is false - */ - editItem: { - type: Object, - required: false, - default: null, - }, - /** - * Update dialog from defaultItem or editItem also if dialog is shown - * This would happen only on mount and if dialog is hidden otherwise. - */ - forceModelItemUpdate: { - type: Boolean, - required: false, - default: false, - }, - /** - * Also inherited props from createOrPatchMixin - */ - }, + mixins: [openableDialogMixin, objectFormPropsMixin, loadingMixin], emits: ["cancel"], - data() { - return { - valid: false, - firstInitDone: false, - itemModel: {}, - }; - }, computed: { dialog: { get() { @@ -178,66 +78,17 @@ export default { }, }, methods: { - dynamicSetter(item, fieldName) { - return (value) => { - this.$set(item, fieldName, value); - }; - }, - buildExternalSetter(item) { - return (fieldName, value) => this.dynamicSetter(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, - }; - }, - cancel() { - this.dialog = false; - /** - * Emitted when user cancels - */ - this.$emit("cancel"); - }, - handleSuccess() { + close() { this.dialog = false; - let snackbarTextKey = this.isCreate - ? this.createSuccessMessageI18nKey - : this.editSuccessMessageI18nKey; - - this.$toastSuccess(this.$t(snackbarTextKey)); - this.resetModel(); - }, - resetModel() { - this.itemModel = JSON.parse( - JSON.stringify(this.isCreate ? this.defaultItem : this.editItem), - ); - }, - updateModel() { - // Only update the model if the dialog is hidden or has just been mounted - if (this.forceModelItemUpdate || !this.firstInitDone || !this.dialog) { - this.resetModel(); - } }, }, + data() { + return { + valid: false, + }; + }, mounted() { - this.$on("save", this.handleSuccess); - - this.updateModel(); - this.firstInitDone = true; - - this.$watch("isCreate", this.updateModel); - this.$watch("defaultItem", this.updateModel, { deep: true }); - this.$watch("editItem", this.updateModel, { deep: true }); + this.$on("cancel", this.close); }, }; </script> diff --git a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue index 63cfd55a6c747c67b30a9f8ba8a2e58331afb929..dc71aa8a0933e7d0491c01b3e5a79857fe8e8178 100644 --- a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue +++ b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue @@ -39,40 +39,50 @@ export default { :is="component" :value="true" fullscreen + scrollable hide-overlay transition="dialog-bottom-transition" v-bind="$attrs" v-on="$listeners" > <v-card elevation="0"> - <v-toolbar v-if="isDialog"> - <slot name="cancel"> - <v-btn icon @click="handleClose"> - <v-icon>$cancel</v-icon> - </v-btn> - </slot> + <v-card-title v-if="isDialog" class="pa-0"> + <v-toolbar> + <slot name="cancel"> + <v-btn icon @click="handleClose"> + <v-icon>$cancel</v-icon> + </v-btn> + </slot> - <v-toolbar-title> - {{ $root.toolbarTitle }} - </v-toolbar-title> + <v-toolbar-title> + <slot name="title"> + {{ $root.toolbarTitle }} + </slot> + </v-toolbar-title> - <v-spacer></v-spacer> + <v-spacer></v-spacer> - <v-toolbar-items> - <slot name="actions" :toolbar="true" /> - </v-toolbar-items> - </v-toolbar> + <v-toolbar-items> + <slot name="toolbarActions" :toolbar="true" /> + </v-toolbar-items> + </v-toolbar> + </v-card-title> - <div - :class="{ - 'main-container': isDialog, - 'pa-3': isDialog, - 'full-width': isDialog && ($route.meta.fullWidth ?? fullWidth), - }" - > - <slot /> - <slot name="actions" v-if="!isDialog" :toolbar="false" /> - </div> + <v-card-text> + <div + :class="{ + 'main-container': isDialog, + 'pa-3': isDialog, + 'full-width': isDialog && ($route.meta.fullWidth ?? fullWidth), + }" + > + <slot /> + </div> + </v-card-text> + + <v-card-actions> + <slot name="actions" :toolbar="false" /> + </v-card-actions> </v-card> </component> </template> diff --git a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue index 5178574b7e02bb236fd24edf0793aaa48bf2e1fb..bd990458f3509672e2e402153900fe0f91e12abb 100644 --- a/aleksis/core/frontend/components/generic/forms/DateTimeField.vue +++ b/aleksis/core/frontend/components/generic/forms/DateTimeField.vue @@ -49,22 +49,12 @@ export default { required: false, default: undefined, }, - minDate: { + min: { type: String, required: false, default: undefined, }, - maxDate: { - type: String, - required: false, - default: undefined, - }, - minTime: { - type: String, - required: false, - default: undefined, - }, - maxTime: { + max: { type: String, required: false, default: undefined, @@ -111,6 +101,32 @@ export default { this.dateTime = newDateTime.toISO(); }, }, + minDT() { + return this.$parseISODate(this.min); + }, + minDate() { + return this.minDT.toISODate(); + }, + minTime() { + if (this.dateTime.hasSame(this.minDT, "day")) { + return this.minDT.toFormat("HH:mm"); + } else { + return undefined; + } + }, + maxDT() { + return this.$parseISODate(this.max); + }, + maxDate() { + return this.maxDT.toISODate(); + }, + maxTime() { + if (this.dateTime.hasSame(this.maxDT, "day")) { + return this.maxDT.toFormat("HH:mm"); + } else { + return undefined; + } + }, }, watch: { value(newValue) { diff --git a/aleksis/core/frontend/components/generic/forms/GroupField.vue b/aleksis/core/frontend/components/generic/forms/GroupField.vue index 3433c5c3fb0695cfd5842ad0a461ab5cfb6aa765..736425d59cfc3e49cad459cd94ca50ff77cc4952 100644 --- a/aleksis/core/frontend/components/generic/forms/GroupField.vue +++ b/aleksis/core/frontend/components/generic/forms/GroupField.vue @@ -13,7 +13,7 @@ <script> import queryMixin from "../../../mixins/queryMixin.js"; -import { groups } from "./group.graphql"; +import { formGroups } from "./group.graphql"; export default { name: "GroupField", @@ -26,7 +26,7 @@ export default { gqlQuery: { type: Object, required: false, - default: () => groups, + default: () => formGroups, }, }, }; diff --git a/aleksis/core/frontend/components/generic/forms/PersonField.vue b/aleksis/core/frontend/components/generic/forms/PersonField.vue index ab852a0475d69c4205b76376cbdcdbc7457aec08..4df1df64d9a33c0d1203af62df7be6e841f3d54d 100644 --- a/aleksis/core/frontend/components/generic/forms/PersonField.vue +++ b/aleksis/core/frontend/components/generic/forms/PersonField.vue @@ -12,7 +12,7 @@ <script> import queryMixin from "../../../mixins/queryMixin.js"; -import { persons } from "./person.graphql"; +import { formPersons } from "./person.graphql"; export default { name: "PersonField", @@ -25,7 +25,7 @@ export default { gqlQuery: { type: Object, required: false, - default: () => persons, + default: () => formPersons, }, }, methods: { diff --git a/aleksis/core/frontend/components/generic/forms/group.graphql b/aleksis/core/frontend/components/generic/forms/group.graphql index 5a4906b549225bab5e2ba98e2ad681fb03d3583e..fcd97f2b49c19ca765b5d5d91b77c36c838bcf89 100644 --- a/aleksis/core/frontend/components/generic/forms/group.graphql +++ b/aleksis/core/frontend/components/generic/forms/group.graphql @@ -1,4 +1,4 @@ -query groups { +query formGroups { items: groups { id shortName diff --git a/aleksis/core/frontend/components/generic/forms/person.graphql b/aleksis/core/frontend/components/generic/forms/person.graphql index 6985dc9428b9c171706e170a35d2bd38e038a7ff..b7aaae4f2e3a8ccc4c54e8f0962ed518d75c5317 100644 --- a/aleksis/core/frontend/components/generic/forms/person.graphql +++ b/aleksis/core/frontend/components/generic/forms/person.graphql @@ -1,4 +1,4 @@ -query persons { +query formPersons { items: persons { id shortName diff --git a/aleksis/core/frontend/components/group/GroupAvatarClickbox.vue b/aleksis/core/frontend/components/group/GroupAvatarClickbox.vue index 160a07501b5d12433fed1d1ac1720e16ca7b0d7a..d78e68ba09bc0b1555fb0d980f555b4ef45fb566 100644 --- a/aleksis/core/frontend/components/group/GroupAvatarClickbox.vue +++ b/aleksis/core/frontend/components/group/GroupAvatarClickbox.vue @@ -1,9 +1,9 @@ <template> <avatar-clickbox> <template #activator> - <avatar-content :imageUrl="url" class="rounded-circle" /> + <avatar-content :image-url="url" class="rounded-circle" /> </template> - <avatar-content :imageUrl="url" contain /> + <avatar-content :image-url="url" contain /> </avatar-clickbox> </template> diff --git a/aleksis/core/frontend/components/group/GroupList.vue b/aleksis/core/frontend/components/group/GroupList.vue index 0b7b5ac115d63a0d04bacaa3f6567786898d2048..cbe3c49dd09e2a16513e9574c81ee03bbf95cfa6 100644 --- a/aleksis/core/frontend/components/group/GroupList.vue +++ b/aleksis/core/frontend/components/group/GroupList.vue @@ -1,7 +1,7 @@ <script> import CRUDList from "../generic/CRUDList.vue"; -import { deleteGroups, groups } from "./groupList.graphql"; +import { deleteGroups, groups } from "./groups.graphql"; import CreateButton from "../generic/buttons/CreateButton.vue"; import TableLink from "../generic/TableLink.vue"; import AvatarContent from "../person/AvatarContent.vue"; @@ -73,7 +73,7 @@ export default { <template #avatarUrl="{ item }"> <table-link :to="{ name: 'core.group', params: { id: item.id } }"> <v-avatar class="my-1 me-2"> - <avatar-content :imageUrl="item.avatarUrl" contain /> + <avatar-content :image-url="item.avatarUrl" contain /> </v-avatar> </table-link> </template> diff --git a/aleksis/core/frontend/components/group/GroupMembers.vue b/aleksis/core/frontend/components/group/GroupMembers.vue index d0dc18a3de9925e40ab2dedaf6bef59c201fb212..b841283bb7bf312ff654b234edb575130f3bf838 100644 --- a/aleksis/core/frontend/components/group/GroupMembers.vue +++ b/aleksis/core/frontend/components/group/GroupMembers.vue @@ -76,7 +76,7 @@ export default { <!-- eslint-disable-next-line vue/valid-v-slot --> <template #item.id="{ item }"> <v-tooltip bottom> - <template v-slot:activator="{ on, attrs }"> + <template #activator="{ on, attrs }"> <secondary-action-button v-bind="attrs" v-on="on" diff --git a/aleksis/core/frontend/components/group/GroupOverview.vue b/aleksis/core/frontend/components/group/GroupOverview.vue index f4afdd07c440f59906c2f82114abcc1e6338774e..990880c8e97bf73fed1aed02dae3b4d0b0e1430b 100644 --- a/aleksis/core/frontend/components/group/GroupOverview.vue +++ b/aleksis/core/frontend/components/group/GroupOverview.vue @@ -30,16 +30,14 @@ export default { required: false, default: null, }, - tabSlug: { - type: String, - required: false, - default: "default", - }, }, computed: { tabs() { return collections.coreGroupOverview.items; }, + tabSlug() { + return this.$hash; + }, }, mounted() { const tab = this.tabs.findIndex((tab) => tab.tab.id === this.tabSlug); @@ -61,8 +59,8 @@ export default { if (this.tabSlug !== tabSlug) { this.$router.push({ - name: "core.groupWithTab", - params: { id: this.id, tabSlug }, + ...this.$route, + hash: "#" + tabSlug, }); } }, diff --git a/aleksis/core/frontend/components/group/groupList.graphql b/aleksis/core/frontend/components/group/groupList.graphql deleted file mode 100644 index 7df6604b94608301147eec994d6f0add1b5d9013..0000000000000000000000000000000000000000 --- a/aleksis/core/frontend/components/group/groupList.graphql +++ /dev/null @@ -1,24 +0,0 @@ -query groups($orderBy: [String], $filters: JSONString) { - items: groups(orderBy: $orderBy, filters: $filters) { - id - shortName - name - avatarUrl - schoolTerm { - id - name - } - groupType { - id - name - } - canEdit - canDelete - } -} - -mutation deleteGroups($ids: [ID]!) { - deleteGroups(ids: $ids) { - deletionCount - } -} diff --git a/aleksis/core/frontend/components/group/groups.graphql b/aleksis/core/frontend/components/group/groups.graphql index 701ecde468d517da81410cb35faba0022c91fa09..cc5e716216d05589f05f2ea65a1655cbf07abb3e 100644 --- a/aleksis/core/frontend/components/group/groups.graphql +++ b/aleksis/core/frontend/components/group/groups.graphql @@ -1,3 +1,22 @@ +query groups($orderBy: [String], $filters: JSONString) { + items: groups(orderBy: $orderBy, filters: $filters) { + id + shortName + name + avatarUrl + schoolTerm { + id + name + } + groupType { + id + name + } + canEdit + canDelete + } +} + query groupById($id: ID) { object: groupById(id: $id) { id diff --git a/aleksis/core/frontend/components/notifications/myNotifications.graphql b/aleksis/core/frontend/components/notifications/myNotifications.graphql index 9fcddc77f1e1f50dac823d308851977bf5cc08b1..d51ef53f22a4724d33d97bd223281ff3e3d1a705 100644 --- a/aleksis/core/frontend/components/notifications/myNotifications.graphql +++ b/aleksis/core/frontend/components/notifications/myNotifications.graphql @@ -2,6 +2,7 @@ query myNotifications { myNotifications: whoAmI { id person { + id notifications { id title diff --git a/aleksis/core/frontend/components/pdf/pdf.graphql b/aleksis/core/frontend/components/pdf/pdf.graphql index 0a5f7eb34e4a6e482615131870bc1fbd3210ef98..e469aaaf5ffd4c5005eb603ce387f9f82cae15d0 100644 --- a/aleksis/core/frontend/components/pdf/pdf.graphql +++ b/aleksis/core/frontend/components/pdf/pdf.graphql @@ -1,5 +1,6 @@ query pdf($id: ID!) { pdf: pdfById(id: $id) { + id file { url } diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue index c9244a80b22a8c8e10b74b79f27e6219408f28bb..5bc9c70558462c10ee76507a94eb2118e76cf66c 100644 --- a/aleksis/core/frontend/components/person/PersonActions.vue +++ b/aleksis/core/frontend/components/person/PersonActions.vue @@ -101,7 +101,8 @@ </template> <script> -import { personActions, deletePersons } from "./personActions.graphql"; +import { personActions } from "./personActions.graphql"; +import { deletePersons } from "./personList.graphql"; import DeleteDialog from "../generic/dialogs/DeleteDialog.vue"; export default { diff --git a/aleksis/core/frontend/components/person/PersonOverview.vue b/aleksis/core/frontend/components/person/PersonOverview.vue index 55826e5cc139eda682343ab4e8d7d2d87f1fdee5..30955d147a5802ec41c1f3453823de73026127a5 100644 --- a/aleksis/core/frontend/components/person/PersonOverview.vue +++ b/aleksis/core/frontend/components/person/PersonOverview.vue @@ -317,24 +317,28 @@ export default { required: false, default: null, }, - widgetSlug: { - type: String, - required: false, - default: "default", - }, + }, + mounted() { + if (this.$route.name == "core.me") { + this.$router.replace({ + name: "core.personById", + params: { id: this.$root.whoAmI.person.id }, + }); + } }, methods: { maximizeWidget(slug) { if (this.widgetSlug !== slug) { if (this.id) { this.$router.push({ - name: "core.personByIdWithSlug", - params: { id: this.id, widgetSlug: slug }, + name: "core.personById", + params: { id: this.id }, + hash: "#" + slug, }); } else { this.$router.push({ - name: "core.personWithSlug", - params: { widgetSlug: slug }, + name: "core.me", + hash: "#" + slug, }); } } @@ -342,13 +346,12 @@ export default { minimizeWidgets() { if (this.id) { this.$router.push({ - name: "core.personByIdWithSlug", - params: { id: this.id, widgetSlug: "default" }, + name: "core.personById", + params: { id: this.id }, }); } else { this.$router.push({ - name: "core.personWithSlug", - params: { widgetSlug: "default" }, + name: "core.me", }); } }, @@ -357,6 +360,9 @@ export default { widgets() { return collections.corePersonWidgets.items; }, + widgetSlug() { + return this.$hash; + }, }, }; </script> diff --git a/aleksis/core/frontend/components/person/personActions.graphql b/aleksis/core/frontend/components/person/personActions.graphql index 1936545144501d6ab9565496a91f7ff30cc9f8c9..be1b9209a9a6a6b3938423e8ccb20eed016b9398 100644 --- a/aleksis/core/frontend/components/person/personActions.graphql +++ b/aleksis/core/frontend/components/person/personActions.graphql @@ -10,9 +10,3 @@ query personActions($id: ID!) { canImpersonatePerson } } - -mutation deletePersons($ids: [ID]!) { - deletePersons(ids: $ids) { - deletionCount - } -} diff --git a/aleksis/core/frontend/components/room/RoomChip.vue b/aleksis/core/frontend/components/room/RoomChip.vue index 1e14588742a276ca1a57476fbc2558e726ba482f..6aa70777d217a3a54b1a6292f2df6d30d817e8ff 100644 --- a/aleksis/core/frontend/components/room/RoomChip.vue +++ b/aleksis/core/frontend/components/room/RoomChip.vue @@ -12,7 +12,7 @@ export default { <template> <v-tooltip bottom> - <template v-slot:activator="{ on, attrs }"> + <template #activator="{ on, attrs }"> <v-chip v-bind="{ ...attrs, ...$attrs }" v-on="{ ...on, ...$listeners }"> <v-avatar> <v-icon> mdi-door </v-icon> diff --git a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue index 3de29edcf36b0661ee5a4dba6a59bee48c565183..e9c0543837faf74b89c6f314bc11c8079bdc70a6 100644 --- a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue +++ b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue @@ -1,7 +1,7 @@ <script> import { activeSchoolTerm, - schoolTerms, + schoolTermsForActiveSchoolTerm, setActiveSchoolTerm, } from "./activeSchoolTerm.graphql"; import loadingMixin from "../../mixins/loadingMixin"; @@ -10,7 +10,7 @@ export default { mixins: [loadingMixin], apollo: { schoolTerms: { - query: schoolTerms, + query: schoolTermsForActiveSchoolTerm, }, activeSchoolTerm: { query: activeSchoolTerm, diff --git a/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql b/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql index 95b5fc170064e4dea9a24a17f3b10ad4bccb5af8..f005e1488979c348af957b4f09710cff9c254032 100644 --- a/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql +++ b/aleksis/core/frontend/components/school_term/activeSchoolTerm.graphql @@ -10,7 +10,7 @@ query activeSchoolTerm { } } -query schoolTerms { +query schoolTermsForActiveSchoolTerm { schoolTerms { id name diff --git a/aleksis/core/frontend/index.js b/aleksis/core/frontend/index.js index 00411a66a968a2e1d1eda36987cc325000634610..6e76c8d354a4ea466fcf9f1bd4ccb0c6070a402f 100644 --- a/aleksis/core/frontend/index.js +++ b/aleksis/core/frontend/index.js @@ -77,6 +77,7 @@ const app = new Vue({ invalidation: false, snackbarItems: [], toolbarTitle: "AlekSIS®", + whoAmI: null, permissions: [], permissionNames: [], frequentCeleryPolling: false, diff --git a/aleksis/core/frontend/mixins/aleksis.js b/aleksis/core/frontend/mixins/aleksis.js index 84ef28e4d4f006e660971fbe05f46de51392b130..24e8726534398713afda9867bc5feff8874f3625 100644 --- a/aleksis/core/frontend/mixins/aleksis.js +++ b/aleksis/core/frontend/mixins/aleksis.js @@ -11,6 +11,11 @@ const aleksisMixin = { $_aleksis_safeTrackedEvents: new Array(), }; }, + computed: { + $hash() { + return this.$route?.hash ? this.$route.hash.substring(1) : ""; + }, + }, methods: { safeAddEventListener(target, event, handler) { console.debug("Safely adding handler for %s on %o", event, target); diff --git a/aleksis/core/frontend/mixins/createOrPatchMixin.js b/aleksis/core/frontend/mixins/createOrPatchMixin.js index 4307ad1549590bdf96d6a1925f855f3ccb6f4b2b..a7ea0d52842df453aec587dc545ac3ddeff2427f 100644 --- a/aleksis/core/frontend/mixins/createOrPatchMixin.js +++ b/aleksis/core/frontend/mixins/createOrPatchMixin.js @@ -1,57 +1,11 @@ import mutateMixin from "./mutateMixin.js"; +import createOrPatchPropsMixin from "./createOrPatchPropsMixin"; /** * This mixin provides item creation or update via graphQL. */ export default { - mixins: [mutateMixin], - props: { - // UPDATE NOTICE: This has the same props the DialogObjectForm used previously - /** - * If isCreate is true the save method will create the object or - * patch it otherwise - * @values true, false - */ - isCreate: { - type: Boolean, - required: false, - default: true, - }, - /** - * The graphQL create mutation - */ - gqlCreateMutation: { - type: Object, - required: false, - default: undefined, - }, - /** - * The graphQL patch mutation - */ - gqlPatchMutation: { - type: Object, - required: false, - default: undefined, - }, - /** - * An optional function to transform a single object prior to creating it - * @values function - */ - getCreateData: { - type: Function, - required: false, - default: (item) => item, - }, - /** - * An optional function to transform a single object prior to patching it - * @values function - */ - getPatchData: { - type: Function, - required: false, - default: (item) => item, - }, - }, + mixins: [mutateMixin, createOrPatchPropsMixin], computed: { provideMutation() { return this.isCreate ? this.gqlCreateMutation : this.gqlPatchMutation; diff --git a/aleksis/core/frontend/mixins/createOrPatchPropsMixin.js b/aleksis/core/frontend/mixins/createOrPatchPropsMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..c9122136924a7d9f0c9f2d320745d6fe03d1c755 --- /dev/null +++ b/aleksis/core/frontend/mixins/createOrPatchPropsMixin.js @@ -0,0 +1,67 @@ +import mutatePropsMixin from "./mutatePropsMixin"; + +/** + * This mixin provides item creation or update via graphQL. + */ +export default { + mixins: [mutatePropsMixin], + props: { + // UPDATE NOTICE: This has the same props the DialogObjectForm used previously + /** + * If isCreate is true the save method will create the object or + * patch it otherwise + * @values true, false + */ + isCreate: { + type: Boolean, + required: false, + default: true, + }, + /** + * The graphQL create mutation + */ + gqlCreateMutation: { + type: Object, + required: false, + default: undefined, + }, + /** + * The graphQL patch mutation + */ + gqlPatchMutation: { + type: Object, + required: false, + default: undefined, + }, + /** + * An optional function to transform a single object prior to creating it + * @values function + */ + getCreateData: { + type: Function, + required: false, + default: (item) => item, + }, + /** + * An optional function to transform a single object prior to patching it + * @values function + */ + getPatchData: { + type: Function, + required: false, + default: (item) => item, + }, + }, + computed: { + createOrPatchProps() { + return { + ...this.mutateProps, + isCreate: this.isCreate, + gqlCreateMutation: this.gqlCreateMutation, + gqlPatchMutation: this.gqlPatchMutation, + getCreateData: this.getCreateData, + getPatchData: this.getPatchData, + }; + }, + }, +}; diff --git a/aleksis/core/frontend/mixins/mutateMixin.js b/aleksis/core/frontend/mixins/mutateMixin.js index 48f914ef79b8de5f153f8d39864abe47cda8b20d..00101a07602a74affc9a2ae627ad555e278a1522 100644 --- a/aleksis/core/frontend/mixins/mutateMixin.js +++ b/aleksis/core/frontend/mixins/mutateMixin.js @@ -1,54 +1,11 @@ import loadingMixin from "./loadingMixin.js"; +import mutatePropsMixin from "./mutatePropsMixin"; /** * This mixin provides generic graphQL mutation handling. */ export default { - mixins: [loadingMixin], - props: { - /** - * The graphQL query this mutation affects. - * If provided the cached query will be updated. - * Either use lastQuery from the queryMixin or - * $apollo.queries[lastQueryName]. - */ - affectedQuery: { - type: Object, - required: false, - default: null, - }, - /** - * Key of the affected query data - * Key can be a single key or nested keys seperated by a '.' - */ - gqlDataKey: { - type: String, - required: false, - default: "items", - }, - // itemId is unused in mutateMixin but shared by both - // createOrPatchMixin & deleteMixin - /** - * The item's id property. - */ - itemId: { - type: String, - required: false, - default: "id", - }, - /** - * Method to perform a custom update. - * - * This is an alternative way of supplying this method. - * In case a method is set here, it has higher priority - * than the one directly submitted to the mutate function. - */ - customUpdate: { - type: Function, - required: false, - default: undefined, - }, - }, + mixins: [loadingMixin, mutatePropsMixin], // update & save come from DialogObjectForm // save could be success as well but keeping it backwards compatible // for now diff --git a/aleksis/core/frontend/mixins/mutatePropsMixin.js b/aleksis/core/frontend/mixins/mutatePropsMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..f992ddaa3f72c038739fb2d2f227a5d6df117807 --- /dev/null +++ b/aleksis/core/frontend/mixins/mutatePropsMixin.js @@ -0,0 +1,62 @@ +import loadingMixin from "./loadingMixin.js"; + +/** + * This mixin provides generic graphQL mutation handling. + */ +export default { + mixins: [loadingMixin], + props: { + /** + * The graphQL query this mutation affects. + * If provided the cached query will be updated. + * Either use lastQuery from the queryMixin or + * $apollo.queries[lastQueryName]. + */ + affectedQuery: { + type: Object, + required: false, + default: null, + }, + /** + * Key of the affected query data + * Key can be a single key or nested keys seperated by a '.' + */ + gqlDataKey: { + type: String, + required: false, + default: "items", + }, + // itemId is unused in mutateMixin but shared by both + // createOrPatchMixin & deleteMixin + /** + * The item's id property. + */ + itemId: { + type: String, + required: false, + default: "id", + }, + /** + * Method to perform a custom update. + * + * This is an alternative way of supplying this method. + * In case a method is set here, it has higher priority + * than the one directly submitted to the mutate function. + */ + customUpdate: { + type: Function, + required: false, + default: undefined, + }, + }, + computed: { + mutateProps() { + return { + affectedQuery: this.affectedQuery, + gqlDataKey: this.gqlDataKey, + itemId: this.itemId, + customUpdate: this.customUpdate, + }; + }, + }, +}; diff --git a/aleksis/core/frontend/mixins/objectFormMixin.js b/aleksis/core/frontend/mixins/objectFormMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..98c4f292575a7e58e371487ec863ec16da1bcdfb --- /dev/null +++ b/aleksis/core/frontend/mixins/objectFormMixin.js @@ -0,0 +1,89 @@ +import createOrPatchMixin from "./createOrPatchMixin"; +import objectFormPropsMixin from "./objectFormPropsMixin"; + +export default { + mixins: [createOrPatchMixin, objectFormPropsMixin], + data() { + return { + valid: false, + firstInitDone: false, + itemModel: {}, + }; + }, + methods: { + dynamicSetter(item, fieldName) { + return (value) => { + this.$set(item, fieldName, value); + }; + }, + buildExternalSetter(item) { + return (fieldName, value) => this.dynamicSetter(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, + }; + }, + cancel() { + /** + * Emitted when user cancels + */ + this.$emit("cancel"); + }, + handleSuccess() { + this.dialog = false; + let snackbarTextKey = this.isCreate + ? this.createSuccessMessageI18nKey + : this.editSuccessMessageI18nKey; + + this.$toastSuccess(this.$t(snackbarTextKey)); + this.resetModel(); + }, + resetModel() { + this.itemModel = JSON.parse( + JSON.stringify(this.isCreate ? this.defaultItem : this.editItem), + ); + }, + updateModel() { + // Only update the model if the dialog is hidden or has just been mounted + if (this.forceModelItemUpdate || !this.firstInitDone || !this.dialog) { + this.resetModel(); + } + }, + submit() { + this.createOrPatch([this.itemModel]); + }, + }, + mounted() { + this.$on("save", this.handleSuccess); + + this.updateModel(); + this.firstInitDone = true; + + this.$watch("isCreate", this.updateModel); + this.$watch("defaultItem", this.updateModel, { deep: true }); + this.$watch("editItem", this.updateModel, { deep: true }); + }, + computed: { + title() { + return this.isCreate + ? this.$t(this.createItemI18nKey) + : this.$t(this.editItemI18nKey); + }, + }, + watch: { + valid(valid) { + this.$emit("update:valid", valid); + }, + }, +}; diff --git a/aleksis/core/frontend/mixins/objectFormPropsMixin.js b/aleksis/core/frontend/mixins/objectFormPropsMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..bbef57b7b980b33c2d8c68b0ffc75ff7889dd362 --- /dev/null +++ b/aleksis/core/frontend/mixins/objectFormPropsMixin.js @@ -0,0 +1,89 @@ +import createOrPatchPropsMixin from "./createOrPatchPropsMixin"; + +export default { + mixins: [createOrPatchPropsMixin], + props: { + /** + * Title if isCreate is true + */ + createItemI18nKey: { + type: String, + required: false, + default: "actions.create", + }, + /** + * Title if isCreate is false + */ + editItemI18nKey: { + type: String, + required: false, + default: "actions.edit", + }, + /** + * SuccessMessage if isCreate is true + */ + createSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_create_success", + }, + /** + * SuccessMessage if isCreate is false + */ + editSuccessMessageI18nKey: { + type: String, + required: false, + default: "status.object_edit_success", + }, + /** + * Fields in dialog-object-form + * + * @values list of field objects + * @example [{text: "Field text", value: "Field value name"} ...] + */ + fields: { + type: Array, + required: true, + }, + /** + * Default item used for creation if isCreate is true + */ + defaultItem: { + type: Object, + required: false, + default: null, + }, + /** + * Item offered for editing if isCreate is false + */ + editItem: { + type: Object, + required: false, + default: null, + }, + /** + * Update dialog from defaultItem or editItem also if dialog is shown + * This would happen only on mount and if dialog is hidden otherwise. + */ + forceModelItemUpdate: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + objectFormProps() { + return { + ...this.createOrPatchProps, + createItemI18nKey: this.createItemI18nKey, + editItemI18nKey: this.editItemI18nKey, + createSuccessMessageI18nKey: this.createSuccessMessageI18nKey, + editSuccessMessageI18nKey: this.editSuccessMessageI18nKey, + fields: this.fields, + defaultItem: this.defaultItem, + editItem: this.editItem, + forceModelItemUpdate: this.forceModelItemUpdate, + }; + }, + }, +}; diff --git a/aleksis/core/frontend/mixins/openableDialogMixin.js b/aleksis/core/frontend/mixins/openableDialogMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..d96f62f58e3d91a9ad8ecea714adb655f3b8e4d7 --- /dev/null +++ b/aleksis/core/frontend/mixins/openableDialogMixin.js @@ -0,0 +1,14 @@ +export default { + props: { + /** + * Dialog state (open or closed) + * @model + * @values true,false + */ + value: { + type: Boolean, + default: false, + }, + }, + emits: ["input"], +}; diff --git a/aleksis/core/frontend/routes.js b/aleksis/core/frontend/routes.js index b4164c69db163ab1211301e60a7ab813cd5ba7ad..1e9db2e4b9685a491b2336a2af4088d2ec2a2bcd 100644 --- a/aleksis/core/frontend/routes.js +++ b/aleksis/core/frontend/routes.js @@ -76,7 +76,7 @@ const routes = [ }, }, { - path: "/calendar", + path: "/calendar/", component: () => import("./components/calendar/CalendarOverview.vue"), name: "core.calendar_overview", meta: { @@ -101,7 +101,7 @@ const routes = [ ], }, { - path: "/people", + path: "people", name: "core.people", component: () => import("./components/Parent.vue"), meta: { @@ -113,7 +113,7 @@ const routes = [ }, children: [ { - path: "/persons", + path: "/persons/", component: () => import("./components/person/PersonList.vue"), name: "core.persons", meta: { @@ -133,7 +133,7 @@ const routes = [ name: "core.createPerson", }, { - path: "/persons/:id(\\d+)/", + path: "/persons/:id(\\d+)", component: () => import("./components/person/PersonOverview.vue"), name: "core.personById", props: true, @@ -158,16 +158,7 @@ const routes = [ name: "core.invitePerson", }, { - path: "/persons/:id(\\d+)/:widgetSlug([^\\s!?\\/*#|]+)", - component: () => import("./components/person/PersonOverview.vue"), - props: true, - name: "core.personByIdWithSlug", - meta: { - titleKey: "person.page_title", - }, - }, - { - path: "/groups", + path: "/groups/", component: () => import("./components/group/GroupList.vue"), name: "core.groups", meta: { @@ -204,16 +195,7 @@ const routes = [ name: "core.editGroup", }, { - path: "/groups/:id(\\d+)/:tabSlug([^\\s!?\\/*#|]+)", - component: () => import("./components/group/GroupOverview.vue"), - props: true, - name: "core.groupWithTab", - meta: { - permission: "core.view_groups_rule", - }, - }, - { - path: "/groups/group_types", + path: "/group_types/", component: () => import("./components/group_type/GroupType.vue"), name: "core.groupTypes", meta: { @@ -375,7 +357,7 @@ const routes = [ name: "core.createDashboardWidget", }, { - path: "/dashboard_widgets/default/", + path: "/dashboard_widgets/default", component: () => import("./components/LegacyBaseTemplate.vue"), props: { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, @@ -560,7 +542,7 @@ const routes = [ name: "core.registerOauthApplication,", }, { - path: "/oauth/applications/:pk(\\d+)/", + path: "/oauth/applications/:pk(\\d+)", component: () => import("./components/LegacyBaseTemplate.vue"), props: { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, @@ -602,7 +584,7 @@ const routes = [ ], }, { - path: "/impersonate/:uid(\\d+)/", + path: "/impersonate/:uid(\\d+)", component: () => import("./components/LegacyBaseTemplate.vue"), props: { byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, @@ -627,9 +609,9 @@ const routes = [ }, }, { - path: "/person/", + path: "/me", component: () => import("./components/person/PersonOverview.vue"), - name: "core.person", + name: "core.me", meta: { inAccountMenu: true, titleKey: "person.account_menu_title", @@ -638,15 +620,6 @@ const routes = [ permission: "core.view_account_rule", }, }, - { - path: "/person/:widgetSlug([^\\s!?\\/*#|]+)/", - component: () => import("./components/person/PersonOverview.vue"), - props: true, - name: "core.personWithSlug", - meta: { - permission: "core.view_account_rule", - }, - }, { path: "/preferences/person/", component: () => import("./components/LegacyBaseTemplate.vue"), diff --git a/aleksis/core/migrations/0070_oauth_token_checksum.py b/aleksis/core/migrations/0070_oauth_token_checksum.py index b091d273be8f03b3b72d39defe0f950412871364..0d346ddf02ab495da1fa3ac5ece67c58167dbb1d 100644 --- a/aleksis/core/migrations/0070_oauth_token_checksum.py +++ b/aleksis/core/migrations/0070_oauth_token_checksum.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='oauthaccesstoken', name='token_checksum', - field=oauth2_provider.models.TokenChecksumField(db_index=True, default='', max_length=64, unique=True, blank=True), + field=oauth2_provider.models.TokenChecksumField(default='', max_length=64, blank=True), preserve_default=False, ), migrations.RunPython(forwards_func, migrations.RunPython.noop), diff --git a/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py b/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py new file mode 100644 index 0000000000000000000000000000000000000000..cd1620e7d5910bbc79d76197f1fe8b622fd280c5 --- /dev/null +++ b/aleksis/core/migrations/0071_constrain_calendar_event_starting_before_ending.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +from django.db.models import F, Q + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0070_oauth_token_checksum'), + ] + + operations = [ + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.CheckConstraint(check=Q(datetime_end__gt=F('datetime_start')), + name="datetime_start_before_end" + ), + ), + migrations.AddConstraint( + model_name='calendarevent', + constraint=models.CheckConstraint(check=Q(date_end__gt=F('date_start')), + name="date_start_before_end" + ), + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index b05625a83f6e8c227eab3aae62ab5b915b3ff030..06bc415e7088499afd0530ce5a4bdd8f9043cf7f 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -15,7 +15,7 @@ from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator from django.db import models -from django.db.models import Q, QuerySet +from django.db.models import F, Q, QuerySet from django.dispatch import receiver from django.forms.widgets import Media from django.http import HttpRequest @@ -1796,6 +1796,12 @@ class CalendarEvent( check=~Q(datetime_end__isnull=True, date_end__isnull=True), name="datetime_end_or_date_end", ), + models.CheckConstraint( + check=Q(datetime_end__gt=F("datetime_start")), name="datetime_start_before_end" + ), + models.CheckConstraint( + check=Q(date_end__gt=F("date_start")), name="date_start_before_end" + ), models.CheckConstraint( check=~(Q(datetime_start__isnull=False, timezone="") & ~Q(recurrences="")), name="timezone_if_datetime_start_and_recurring", diff --git a/aleksis/core/schema/personal_event.py b/aleksis/core/schema/personal_event.py index 3bf36b2e4c352cee626529002897cfc6095b6e3e..e8655d160c0841332cc0edb20764f9a53d22bae3 100644 --- a/aleksis/core/schema/personal_event.py +++ b/aleksis/core/schema/personal_event.py @@ -24,15 +24,14 @@ class PersonalEventType(DjangoObjectType): "location", "datetime_start", "datetime_end", - "timezone", "date_start", "date_end", "owner", "persons", "groups", ) - convert_choices_to_enum = False + timezone = graphene.String() recurrences = graphene.String() @@ -53,7 +52,12 @@ class PersonalEventBatchCreateMutation(PermissionBatchPatchMixin, BaseBatchCreat "persons", "groups", ) - field_types = {"recurrences": graphene.String(), "location": graphene.String()} + field_types = { + "timezone": graphene.String(), + "recurrences": graphene.String(), + "location": graphene.String(), + } + optional_fields = ("timezone", "recurrences") @classmethod def get_permissions(cls, root, info, input) -> Iterable[str]: # noqa @@ -102,10 +106,24 @@ class PersonalEventBatchPatchMutation(BaseBatchPatchMutation): "persons", "groups", ) - field_types = {"recurrences": graphene.String(), "location": graphene.String()} + field_types = { + "timezone": graphene.String(), + "recurrences": graphene.String(), + "location": graphene.String(), + } + optional_fields = ("timezone", "recurrences") @classmethod def get_permissions(cls, root, info, input, id, obj) -> Iterable[str]: # noqa if info.context.user.has_perm("core.edit_personal_event_rule", obj): return [] return cls._meta.permissions + + @classmethod + def before_mutate(cls, root, info, input): # noqa + for event in input: + # Remove recurrences if none were received. + if "recurrences" not in event: + event["recurrences"] = "" + + return input diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 11b27b5a16825109cbe8fce44b5b3a4ee2110bdd..35c05de1361055a15bfab5f4ad013d29a6f37ec3 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -376,7 +376,7 @@ ACCOUNT_EMAIL_SUBJECT_PREFIX = _settings.get("auth.registration.subject", "[Alek ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = True # Enforce uniqueness of email addresses -ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", True) +ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.registration.unique_email", True) # Configurable username validators ACCOUNT_USERNAME_VALIDATORS = "aleksis.core.util.auth_helpers.custom_username_validators" diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 6427c984e6e23fda234461128fd59e57a3b2378b..16e290332c434115edca2870480e5ede0fca1e01 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -167,11 +167,13 @@ urlpatterns = [ name="persons", ), path( - "person/", TemplateView.as_view(template_name="core/empty.html"), name="person" + "me", + TemplateView.as_view(template_name="core/empty.html"), + name="person", ), path("persons/create/", views.CreatePersonView.as_view(), name="create_person"), path( - "persons/<int:id_>/", + "persons/<int:id_>", TemplateView.as_view(template_name="core/empty.html"), name="person_by_id", ), @@ -190,7 +192,7 @@ urlpatterns = [ ), path("groups/create/", views.edit_group, name="create_group"), path( - "groups/<int:id_>/", + "groups/<int:id_>", TemplateView.as_view(template_name="core/empty.html"), name="group_by_id", ), @@ -200,12 +202,12 @@ urlpatterns = [ path("announcements/", views.announcements, name="announcements"), path("announcements/create/", views.announcement_form, name="add_announcement"), path( - "announcements/edit/<int:id_>/", + "announcements/edit/<int:id_>", views.announcement_form, name="edit_announcement", ), path( - "announcements/delete/<int:id_>/", + "announcements/delete/<int:id_>", views.delete_announcement, name="delete_announcement", ), @@ -224,7 +226,7 @@ urlpatterns = [ name="register_oauth_application", ), path( - "oauth/applications/<int:pk>/", + "oauth/applications/<int:pk>", views.OAuth2DetailView.as_view(), name="oauth2_application", ), @@ -361,7 +363,7 @@ urlpatterns = [ name="create_dashboard_widget", ), path( - "dashboard_widgets/default/", + "dashboard_widgets/default", views.EditDashboardView.as_view(), {"default": True}, name="edit_default_dashboard", diff --git a/pyproject.toml b/pyproject.toml index 071760087ed9694fea3bc27d081952e484c422b2..48b4290e640c69a69a9d61cf7d52b7f3af7370d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ django-celery-results = "^2.5.1" django-celery-beat = "^2.6.0" django-celery-email = "^3.0.0" django-polymorphic = "^3.0.0" -django-colorfield = "^0.11.0" +django-colorfield = "^0.12.0" django-bleach = "^3.0.0" django-guardian = "^2.2.0" rules = "^3.0" diff --git a/tox.ini b/tox.ini index ffdea8542dd406b1b9a03f349f3828e0394a7fb3..7bccb6bf67559b0150b61d36c83f5035ff7856a7 100644 --- a/tox.ini +++ b/tox.ini @@ -24,12 +24,13 @@ setenv = [testenv:lint] commands_pre = - poetry install --only=dev + poetry install yarnpkg --cwd=.dev-js commands = poetry run ruff check {posargs} aleksis/ yarnpkg --cwd=.dev-js run prettier --ignore-path={toxinidir}/.prettierignore {posargs} --check .. - yarnpkg --cwd=.dev-js run eslint ../aleksis/**/*/frontend/**/*.{js,vue} --config={toxinidir}/.eslintrc.js --resolve-plugins-relative-to=. + poetry run aleksis-admin graphql_schema --schema aleksis.core.schema.schema --out .dev-js/schema.json + yarnpkg --cwd=.dev-js run eslint ../aleksis/**/*/frontend/**/*.{js,vue,graphql} --config={toxinidir}/.dev-js/.eslintrc.js [testenv:security] commands_pre =