diff --git a/.eslintrc.js b/.eslintrc.js index 15b0fbe2891c2f685e867206fc6954620237b5d6..4c2043012828bd16438eb4f36472ad48460eb6e4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,9 +1,15 @@ module.exports = { extends: [ - 'plugin:vue/strongly-recommended', + "eslint:recommended", + "plugin:vue/strongly-recommended", + "prettier", ], rules: { - 'vue/no-unused-vars': 'off', - 'vue/multi-word-component-names': 'off' - } -} + "vue/no-unused-vars": "off", + "vue/multi-word-component-names": "off", + }, + env: { + browser: true, + node: true, + }, +}; diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 564449be1717a97282b53b2d94f9f234b34261c2..cf02f39e36aa6fa715f8e3c73c8069433c44f497 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,21 +1,21 @@ include: - - project: "AlekSIS/official/AlekSIS" - file: /ci/general.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/prepare/lock.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/test/test.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/test/lint.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/test/security.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/build/dist.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/publish/pypi.yml - - project: "AlekSIS/official/AlekSIS" - file: /ci/docker/image.yml - - project: "AlekSIS/official/AlekSIS" - file: "/ci/deploy/review.yml" - - project: "AlekSIS/official/AlekSIS" - file: "/ci/deploy/trigger_dist.yml" + - project: "AlekSIS/official/AlekSIS" + file: /ci/general.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/prepare/lock.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/test/test.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/test/lint.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/test/security.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/build/dist.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/publish/pypi.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/docker/image.yml + - project: "AlekSIS/official/AlekSIS" + file: "/ci/deploy/review.yml" + - project: "AlekSIS/official/AlekSIS" + file: "/ci/deploy/trigger_dist.yml" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..81a9d0fcb7eb41ce3cccfe351836df790bd2203c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,87 @@ +# Byte-compiled / optimized / DLL files +*$py.class +*.py[cod] +__pycache__/ + +# Distribution / packaging +*.egg +*.egg-info/ +.Python +.eggs/ +.installed.cfg +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ + +# Installer logs +pip-delete-this-directory.txt +pip-log.txt + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# pyenv +.python-version + +# Environments +.env +.venv +ENV/ +env/ +venv/ + +# Editors +*~ +DEADJOE +\#*# + +# IntelliJ +.idea +.idea/ + +# Database +db.sqlite3 + +# Sphinx +docs/_build/ + +# TeX +*.aux + +# Generated files +/node_modules/ +/static/ +/whoosh_index/ +poetry.lock + +.coverage +.mypy_cache/ +.tox/ +htmlcov/ +maintenance_mode_state.txt +media/ +package-lock.json +yarn.lock + +# VSCode +.vscode/ +.history/ +*.code-workspace + +/cache + +# Add HTML files to avoid problems with unsupported Django templates +*.html diff --git a/.stylelintrc.json b/.stylelintrc.json index 40db42c6689bd157e91cec65fda28693350b6332..2e8ff5864a48be6a22bd1742c2317556a8ec9419 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,3 +1,3 @@ { - "extends": "stylelint-config-standard" + "extends": ["stylelint-config-standard", "stylelint-config-prettier"] } diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c7bd26b7e8e94374e84f1d9c3252832610f6c2c5..e1c561ca6408c49b5d3fa3b084ce4b0e9818ec5f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,8 @@ Changed * Rewrite of frontend using Vuetify * [Dev] Provide function to generate PDF files from fully-rendered templates. * OIDC scope "profile" now exposes the avatar instead of the official photo +* [Dev] Accept pre-created file object for PDF generation to define + the redirect URL in advance. Fixed ~~~~~ diff --git a/README.rst b/README.rst index 290669c5779841ac7713f7f8004cc3141de0b1f9..a7a156f734ab9bc96e0b549177587e22d848898b 100644 --- a/README.rst +++ b/README.rst @@ -68,9 +68,10 @@ Licence Copyright © 2019, 2020, 2021, 2022 Dominik George <dominik.george@teckids.org> Copyright © 2019, 2020, 2021, 2022 Tom Teichler <tom.teichler@teckids.org> Copyright © 2019 mirabilos <thorsten.glaser@teckids.org> + Copyright © 2021, 2022 magicfelix <felix@felix-zauberer.de> Copyright © 2021 Lloyd Meins <meinsll@katharineum.de> - Copyright © 2021 magicfelix <felix@felix-zauberer.de> Copyright © 2022 Benedict Suska <benedict.suska@teckids.org> + Copyright © 2022 Lukas Weichelt <lukas.weichelt@teckids.org> Licenced under the EUPL, version 1.2 or later, by Teckids e.V. (Bonn, Germany). diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 4e7229d81e00d4f5d9f4ecccce86a703bee6befa..096d7effb318402480dcb35bb244a96d59b0d156 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -43,9 +43,10 @@ class CoreConfig(AppConfig): ([2019, 2020, 2021, 2022], "Dominik George", "dominik.george@teckids.org"), ([2019, 2020, 2021, 2022], "Tom Teichler", "tom.teichler@teckids.org"), ([2019], "mirabilos", "thorsten.glaser@teckids.org"), + ([2021, 2022], "magicfelix", "felix@felix-zauberer.de"), ([2021], "Lloyd Meins", "meinsll@katharineum.de"), - ([2021], "magicfelix", "felix@felix-zauberer.de"), ([2022], "Benedict Suska", "benedict.suska@teckids.org"), + ([2022], "Lukas Weichelt", "lukas.weichelt@teckids.org"), ) def ready(self): diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js index 32d4f1b0ed34fea9327b7b1c83257b15009eabd1..3fda256d7740305515809b037d1868d4e7792305 100644 --- a/aleksis/core/assets/app.js +++ b/aleksis/core/assets/app.js @@ -1,131 +1,141 @@ -import Vue from "vue" -import VueRouter from "vue-router" -import Vuetify from "vuetify" -import "vuetify/dist/vuetify.min.css" +import Vue from "vue"; +import VueRouter from "vue-router"; +import Vuetify from "vuetify"; +import "vuetify/dist/vuetify.min.css"; -import ApolloClient from 'apollo-boost' -import VueApollo from 'vue-apollo' -import gql from 'graphql-tag' +import ApolloClient from "apollo-boost"; +import VueApollo from "vue-apollo"; -import "./css/global.scss" -import VueI18n from 'vue-i18n' +import "./css/global.scss"; +import VueI18n from "vue-i18n"; -import messages from "./messages.json" +import messages from "./messages.json"; -Vue.use(VueI18n) +Vue.use(VueI18n); const i18n = new VueI18n({ - locale: "en", - fallbackLocale: "en", - messages + locale: "en", + fallbackLocale: "en", + messages, }); // Using this function, apps can register their locale files i18n.registerLocale = function (messages) { - for (let locale in messages) { - i18n.mergeLocaleMessage(locale, messages[locale]); - } + for (let locale in messages) { + i18n.mergeLocaleMessage(locale, messages[locale]); + } }; -Vue.use(Vuetify) -Vue.use(VueRouter) +Vue.use(Vuetify); +Vue.use(VueRouter); const vuetify = new Vuetify({ - // TODO: load theme data dynamically - // - find a way to load template context data - // - include all site preferences - // - load menu stuff to render the sidenav - icons: { - iconfont: 'mdi', // default - only for display purposes - values: { - cancel: 'mdi-close-circle-outline', - delete: 'mdi-close-circle-outline', - success: 'mdi-check-circle-outline', - info: 'mdi-information-outline', - warning: 'mdi-alert-outline', - error: 'mdi-alert-octagon-outline', - prev: 'mdi-chevron-left', - next: 'mdi-chevron-right', - checkboxOn: 'mdi-checkbox-marked-outline', - checkboxIndeterminate: 'mdi-minus-box-outline', - edit: 'mdi-pencil-outline', - }, + // TODO: load theme data dynamically + // - find a way to load template context data + // - include all site preferences + // - load menu stuff to render the sidenav + icons: { + iconfont: "mdi", // default - only for display purposes + values: { + cancel: "mdi-close-circle-outline", + delete: "mdi-close-circle-outline", + success: "mdi-check-circle-outline", + info: "mdi-information-outline", + warning: "mdi-alert-outline", + error: "mdi-alert-octagon-outline", + prev: "mdi-chevron-left", + next: "mdi-chevron-right", + checkboxOn: "mdi-checkbox-marked-outline", + checkboxIndeterminate: "mdi-minus-box-outline", + edit: "mdi-pencil-outline", }, - theme: { - dark: JSON.parse(document.getElementById("design-mode").textContent) === "dark", - themes: { - light: { - primary: JSON.parse(document.getElementById("primary-color").textContent), - secondary: JSON.parse(document.getElementById("secondary-color").textContent), - }, - dark: { - primary: JSON.parse(document.getElementById("primary-color").textContent), - secondary: JSON.parse(document.getElementById("secondary-color").textContent), - }, - }, + }, + theme: { + dark: + JSON.parse(document.getElementById("design-mode").textContent) === "dark", + themes: { + light: { + primary: JSON.parse( + document.getElementById("primary-color").textContent + ), + secondary: JSON.parse( + document.getElementById("secondary-color").textContent + ), + }, + dark: { + primary: JSON.parse( + document.getElementById("primary-color").textContent + ), + secondary: JSON.parse( + document.getElementById("secondary-color").textContent + ), + }, }, -}) + }, +}); const apolloClient = new ApolloClient({ - uri: JSON.parse(document.getElementById("graphql-url").textContent) -}) + uri: JSON.parse(document.getElementById("graphql-url").textContent), +}); import CacheNotification from "./components/CacheNotification.vue"; import LanguageForm from "./components/LanguageForm.vue"; import MessageBox from "./components/MessageBox.vue"; import NotificationList from "./components/notifications/NotificationList.vue"; import SidenavSearch from "./components/SidenavSearch.vue"; +import CeleryProgressBottom from "./components/celery_progress/CeleryProgressBottom.vue"; Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it -Vue.use(VueApollo) +Vue.use(VueApollo); const apolloProvider = new VueApollo({ defaultClient: apolloClient, -}) +}); const router = new VueRouter({ mode: "history", -// routes: [ -// { path: "/", component: "TheApp" }, -// } + // routes: [ + // { path: "/", component: "TheApp" }, + // } }); const app = new Vue({ - el: '#app', - apolloProvider, - vuetify: vuetify, - // delimiters: ["<%","%>"] // FIXME: discuss new delimiters, [[ <% [{ {[ <[ (( … - data: () => ({ - drawer: vuetify.framework.breakpoint.lgAndUp, - group: null, // what does this mean? - urls: window.Urls, - django: window.django, - // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere - showCacheAlert: false, - systemProperties: { - currentLanguage: "en", - availableLanguages: [], - }, - }), - apollo: { - systemProperties: require("./systemProperties.graphql"), + el: "#app", + apolloProvider, + vuetify: vuetify, + // delimiters: ["<%","%>"] // FIXME: discuss new delimiters, [[ <% [{ {[ <[ (( … + data: () => ({ + drawer: vuetify.framework.breakpoint.lgAndUp, + group: null, // what does this mean? + urls: window.Urls, + django: window.django, + // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere + showCacheAlert: false, + systemProperties: { + currentLanguage: "en", + availableLanguages: [], }, - watch: { - systemProperties: function (newProperties) { - this.$i18n.locale = newProperties.currentLanguage; - this.$vuetify.lang.current = newProperties.currentLanguage; - } + }), + apollo: { + systemProperties: require("./systemProperties.graphql"), + }, + watch: { + systemProperties: function (newProperties) { + this.$i18n.locale = newProperties.currentLanguage; + this.$vuetify.lang.current = newProperties.currentLanguage; }, - components: { - "cache-notification": CacheNotification, - "language-form": LanguageForm, - "notification-list": NotificationList, - "sidenav-search": SidenavSearch, - }, - router, - i18n -}) + }, + components: { + "cache-notification": CacheNotification, + "language-form": LanguageForm, + "notification-list": NotificationList, + "sidenav-search": SidenavSearch, + CeleryProgressBottom, + }, + router, + i18n, +}); window.app = app; window.router = router; diff --git a/aleksis/core/assets/components/BackButton.vue b/aleksis/core/assets/components/BackButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..eaa305f7c3c8f2ca17f53f709870459e9cc790bd --- /dev/null +++ b/aleksis/core/assets/components/BackButton.vue @@ -0,0 +1,12 @@ +<template> + <v-btn color="secondary" v-bind="$attrs"> + <v-icon left>mdi-chevron-left</v-icon> + {{ $t("actions.back") }} + </v-btn> +</template> + +<script> +export default { + name: "BackButton", +}; +</script> diff --git a/aleksis/core/assets/components/CacheNotification.vue b/aleksis/core/assets/components/CacheNotification.vue index d636a056cab92f3464a602d9a02af923197c2a35..f30adbd0bf8f02016216d9285382f7e68e30649c 100644 --- a/aleksis/core/assets/components/CacheNotification.vue +++ b/aleksis/core/assets/components/CacheNotification.vue @@ -1,25 +1,25 @@ <template> <message-box :value="cache" type="warning"> - {{ $t('alerts.page_cached') }} + {{ $t("alerts.page_cached") }} </message-box> </template> <script> - export default { - name: "cache-notification", - data () { - return { - cache: false, - } - }, - created() { - this.channel = new BroadcastChannel("cache-or-not"); - this.channel.onmessage = (event) => { - this.cache = event.data === true; - } - }, - destroyed(){ - this.channel.close() - }, - } +export default { + name: "CacheNotification", + data() { + return { + cache: false, + }; + }, + created() { + this.channel = new BroadcastChannel("cache-or-not"); + this.channel.onmessage = (event) => { + this.cache = event.data === true; + }; + }, + destroyed() { + this.channel.close(); + }, +}; </script> diff --git a/aleksis/core/assets/components/LanguageForm.vue b/aleksis/core/assets/components/LanguageForm.vue index 2897d3a3057c8e11211bc16149b137aa573e1021..d9b0d0b707e279500701350affb843388161e1ba 100644 --- a/aleksis/core/assets/components/LanguageForm.vue +++ b/aleksis/core/assets/components/LanguageForm.vue @@ -1,31 +1,31 @@ <template> <v-menu offset-y> - <template v-slot:activator="{ on, attrs }"> - <v-btn - depressed - v-bind="attrs" - v-on="on" - color="primary" - > + <template #activator="{ on, attrs }"> + <v-btn depressed v-bind="attrs" v-on="on" color="primary"> <v-icon icon color="white">mdi-translate</v-icon> {{ $i18n.locale }} </v-btn> </template> <v-list id="language-dropdown" class="dropdown-content" min-width="150"> <v-skeleton-loader - v-if="!$root.systemProperties.availableLanguages" - class="mx-auto" - type="list-item, list-item, list-item" + v-if="!$root.systemProperties.availableLanguages" + class="mx-auto" + type="list-item, list-item, list-item" ></v-skeleton-loader> <v-list-item-group - v-if="$root.systemProperties.availableLanguages" - v-model="$i18n.locale" - color="primary" + v-if="$root.systemProperties.availableLanguages" + v-model="$i18n.locale" + color="primary" > - <v-list-item v-for="languageOption in $root.systemProperties.availableLanguages" :key="languageOption.code" - :value="languageOption.code" - @click="setLanguage(languageOption)"> - <v-list-item-title>{{ languageOption.nameTranslated }}</v-list-item-title> + <v-list-item + v-for="languageOption in $root.systemProperties.availableLanguages" + :key="languageOption.code" + :value="languageOption.code" + @click="setLanguage(languageOption)" + > + <v-list-item-title>{{ + languageOption.nameTranslated + }}</v-list-item-title> </v-list-item> </v-list-item-group> </v-list> @@ -36,8 +36,8 @@ export default { data: function () { return { - language: this.$i18n.locale - } + language: this.$i18n.locale, + }; }, methods: { setLanguage: function (languageOption) { @@ -46,6 +46,6 @@ export default { this.$vuetify.lang.current = languageOption.code; }, }, - name: "language-form", -} + name: "LanguageForm", +}; </script> diff --git a/aleksis/core/assets/components/MessageBox.vue b/aleksis/core/assets/components/MessageBox.vue index 2a4cb17295835f5d3d1eb6f310a935eb4c2789be..4c79f4d51a36123f4e91f4e187e594d83387e1d3 100644 --- a/aleksis/core/assets/components/MessageBox.vue +++ b/aleksis/core/assets/components/MessageBox.vue @@ -1,15 +1,15 @@ <script> - export default { - name: "message-box", - // Due to this component being a wrapper to a v-alert, all props of this can be used (and overridden). - } +export default { + name: "MessageBox", + // Due to this component being a wrapper to a v-alert, all props of this can be used (and overridden). +}; </script> <template> - <v-alert border="left" text v-bind="$attrs"> - <slot> - Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - </slot> - </v-alert> + <v-alert border="left" text v-bind="$attrs"> + <slot> + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. + </slot> + </v-alert> </template> - diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue index 30fdbe9596487061d962c82af968f659fa524e5f..58bf7bf6d718302279cd9d2d5d9451b6bdf9a644 100644 --- a/aleksis/core/assets/components/SidenavSearch.vue +++ b/aleksis/core/assets/components/SidenavSearch.vue @@ -1,21 +1,68 @@ <script> - export default { - methods: { - submit: function () { - this.$refs.form.submit() - }, +export default { + methods: { + submit: function () { + this.$refs.form.submit(); }, - props: ["action", "placeholder"], - name: "sidenav-search", - } - // FIXME: implement suggestions etc, use "loading" attribute + }, + props: { + action: { + type: String, + required: true, + }, + }, + name: "SidenavSearch", + data() { + return { + q: "", + }; + }, +}; </script> <template> - <form method="get" ref="form" :action="action" id="search-form"> - <v-text-field - :append-icon="'mdi-magnify'" @click:append="submit" single-line - id="search" name="q" type="search" enterkeyhint="search" :placeholder="placeholder" - ></v-text-field> - </form> + <ApolloQuery + :query="require('./searchSnippets.graphql')" + :variables="{ + q, + }" + :skip="!q" + > + <template #default="{ result: { error, data }, isLoading, query }"> + <form method="get" ref="form" :action="action" id="search-form"> + <input type="hidden" name="q" :value="q" /> + <v-autocomplete + :prepend-icon="'mdi-magnify'" + append-icon="" + @click:prepend="submit" + single-line + clearable + :loading="!!isLoading" + id="search" + type="search" + enterkeyhint="search" + :label="$t('actions.search')" + :search-input.sync="q" + flat + solo + cache-items + hide-no-data + hide-details + :items="data ? data.searchSnippets : undefined" + > + <template #item="{ item }"> + <v-list-item :href="item.obj.absoluteUrl"> + <v-list-item-icon v-if="item.obj.icon"> + <v-icon>{{ "mdi-" + item.obj.icon }}</v-icon> + </v-list-item-icon> + <v-list-item-content> + <v-list-item-title> {{ item.obj.name }}</v-list-item-title> + <v-list-item-subtitle>{{ item.text }}</v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + </template> + </v-autocomplete> + </form> + </template> + </ApolloQuery> </template> diff --git a/aleksis/core/assets/components/about/About.vue b/aleksis/core/assets/components/about/About.vue new file mode 100644 index 0000000000000000000000000000000000000000..6187d7c84503ce8633dc9a9559a88ac86b725d37 --- /dev/null +++ b/aleksis/core/assets/components/about/About.vue @@ -0,0 +1,16 @@ +<template> + <div class="mt-4 mb-4"> + <about-aleksis></about-aleksis> + <installed-apps-list /> + </div> +</template> + +<script> +import InstalledAppsList from "./InstalledAppsList.vue"; +import AboutAleksis from "./AboutAleksis.vue"; + +export default { + name: "About", + components: { AboutAleksis, InstalledAppsList }, +}; +</script> diff --git a/aleksis/core/assets/components/about/AboutAleksis.vue b/aleksis/core/assets/components/about/AboutAleksis.vue new file mode 100644 index 0000000000000000000000000000000000000000..2f39581d430a06b57bec0a9367983023ddbb9f95 --- /dev/null +++ b/aleksis/core/assets/components/about/AboutAleksis.vue @@ -0,0 +1,65 @@ +<template> + <v-row class="mb-3"> + <v-col cols="12"> + <v-card class="d-flex flex-column"> + <v-card-title>{{ $t("about.about_aleksis") }}</v-card-title> + <v-card-text> + <p class="text-body-1"> + {{ $t("about.about_aleksis_1") }} + </p> + <p class="text-body-1"> + {{ $t("about.about_aleksis_2") }} + </p> + </v-card-text> + <v-spacer></v-spacer> + <v-card-actions> + <v-btn text color="primary" href="https://aleksis.org/">{{ + $t("about.website_of_aleksis") + }}</v-btn> + <v-btn text color="primary" href="https://edugit.org/AlekSIS/">{{ + $t("about.source_code") + }}</v-btn> + </v-card-actions> + </v-card> + </v-col> + <v-col cols="12"> + <v-card class="d-flex flex-column"> + <v-card-title>{{ $t("about.licence_information") }}</v-card-title> + <v-card-text> + <p> + {{ $t("about.licence_information_1") }} + </p> + <p> + <v-chip color="green" text-color="white" small>{{ + $t("about.free_open_source_licence") + }}</v-chip> + <v-chip color="orange" text-color="white" small>{{ + $t("about.other_licence") + }}</v-chip> + </p> + </v-card-text> + <v-spacer></v-spacer> + <v-card-actions> + <v-btn text color="primary" href="https://eupl.eu">{{ + $t("about.full_licence_text") + }}</v-btn> + <v-btn + text + color="primary" + href="https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers" + > + {{ $t("about.more_information_eupl") }} + </v-btn> + </v-card-actions> + </v-card> + </v-col> + </v-row> +</template> + +<script> +export default { + name: "AboutAleksis", +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/assets/components/about/InstalledAppCard.vue b/aleksis/core/assets/components/about/InstalledAppCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..796808abd127324caf48d71e5e79afcc24da8380 --- /dev/null +++ b/aleksis/core/assets/components/about/InstalledAppCard.vue @@ -0,0 +1,131 @@ +<template> + <v-col cols="12" md="6" lg="6" xl="4" class="d-flex align-stretch"> + <v-card :id="app.name" class="d-flex flex-column flex-grow-1"> + <v-card-title> + {{ app.verboseName }} + </v-card-title> + + <v-card-subtitle class="text-body-1 black--text"> + {{ app.version }} + </v-card-subtitle> + + <v-card-text> + <v-row v-if="app.licence" class="mb-2"> + <v-col cols="6"> + {{ $t("about.licenced_under") }} <br /> + <strong class="text-body-1 black--text">{{ + app.licence.verboseName + }}</strong> + </v-col> + <v-col cols="6"> + {{ $t("about.licence_type") }} <br /> + <v-chip + v-if="app.licence.flags.isFsfLibre" + color="green" + text-color="white" + small + > + {{ $t("about.free_software") }} + </v-chip> + <v-chip + v-else-if="app.licence.flags.isOsiApproved" + color="green" + text-color="white" + small + > + {{ $t("about.open_source") }} + </v-chip> + <v-chip v-else color="orange" text-color="white" small> + {{ $t("about.proprietary") }} + </v-chip> + </v-col> + + <v-col cols="12" v-if="app.licence.licences.length !== 0"> + {{ $t("about.licence_consists_of") }} + <div + v-for="licence in app.licence.licences" + class="mb-2" + :key="licence.name" + > + <v-chip + v-if="licence.isOsiApproved || licence.isFsfLibre" + color="green" + text-color="green" + outlined + small + :href="licence.url" + > + {{ licence.name }} + </v-chip> + <v-chip + v-else + color="orange" + text-color="orange" + outlined + :href="licence.url" + > + {{ licence.name }} + </v-chip> + </div> + </v-col> + </v-row> + </v-card-text> + + <v-spacer></v-spacer> + + <v-card-actions v-if="app.urls.length !== 0"> + <v-btn text color="primary" @click="reveal = true"> + Show copyright + </v-btn> + <v-btn + v-for="url in app.urls" + color="primary" + text + :href="url.url" + :key="url.url" + > + {{ url.name }} + </v-btn> + </v-card-actions> + + <v-expand-transition> + <v-card + v-if="reveal" + class="transition-fast-in-fast-out v-card--reveal d-flex flex-column" + > + <v-card-text class="pb-0"> + <v-row> + <v-col cols="12" v-if="app.copyrights.length !== 0"> + <span v-for="(copyright, index) in app.copyrights" :key="index"> + Copyright © {{ copyright.years }} + <a :href="'mailto:' + copyright.email">{{ + copyright.name + }}</a> + <br /> + </span> + </v-col> + </v-row> + </v-card-text> + <v-spacer></v-spacer> + <v-card-actions class="pt-0"> + <v-btn text color="primary" @click="reveal = false"> Close </v-btn> + </v-card-actions> + </v-card> + </v-expand-transition> + </v-card> + </v-col> +</template> +<script> +export default { + name: "InstalledAppCard", + data: () => ({ + reveal: false, + }), + props: { + app: { + type: Object, + required: true, + }, + }, +}; +</script> diff --git a/aleksis/core/assets/components/about/InstalledAppsList.vue b/aleksis/core/assets/components/about/InstalledAppsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..491e3df15ece49cf736f037212a0bc668a924bed --- /dev/null +++ b/aleksis/core/assets/components/about/InstalledAppsList.vue @@ -0,0 +1,39 @@ +<template> + <ApolloQuery :query="require('./installedApps.graphql')"> + <template #default="{ result: { error, data }, isLoading }"> + <v-row v-if="isLoading"> + <v-col + v-for="idx in 3" + :key="idx" + cols="12" + md="6" + lg="6" + xl="4" + class="d-flex align-stretch" + > + <v-card class="d-flex flex-column flex-grow-1 pa-4"> + <v-skeleton-loader + type="heading, actions, text@5" + ></v-skeleton-loader> + </v-card> + </v-col> + </v-row> + <v-row v-if="data.installedApps"> + <installed-app-card + v-for="app in data.installedApps" + :key="app.name" + :app="app" + /> + </v-row> + </template> + </ApolloQuery> +</template> + +<script> +import InstalledAppCard from "./InstalledAppCard.vue"; + +export default { + name: "InstalledAppsList", + components: { InstalledAppCard }, +}; +</script> diff --git a/aleksis/core/assets/components/about/installedApps.graphql b/aleksis/core/assets/components/about/installedApps.graphql new file mode 100644 index 0000000000000000000000000000000000000000..01ceaf99eb8d79d44ee7014d9f582d7dfeb54ace --- /dev/null +++ b/aleksis/core/assets/components/about/installedApps.graphql @@ -0,0 +1,29 @@ +{ + installedApps { + name + verboseName + version + copyrights { + years + name + email + } + licence { + verboseName + flags { + isFsfLibre + isOsiApproved + } + licences { + isFsfLibre + isOsiApproved + name + url + } + } + urls { + name + url + } + } +} diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue new file mode 100644 index 0000000000000000000000000000000000000000..06b28fc37f52f5440442815207d901fe42cdcd69 --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue @@ -0,0 +1,130 @@ +<template> + <v-row> + <v-col sm="0" md="1" lg="2" xl="3" /> + <v-col sm="12" md="10" lg="8" xl="6"> + <v-card :loading="$apollo.loading"> + <v-card-title v-if="progress"> + {{ progress.meta.title }} + </v-card-title> + <v-card-text v-if="progress"> + <v-progress-linear + :value="progress.progress.percent" + buffer-value="0" + color="primary" + class="mb-2" + stream + /> + <div class="text-center mb-4"> + {{ + progress.meta.progressTitle + ? progress.meta.progressTitle + : $t("celery_progress.progress_title") + }} + </div> + <div v-if="progress"> + <message-box + v-for="(message, idx) in progress.messages" + dense + :type="message.tag" + transition="slide-x-transition" + :key="idx" + > + {{ message.message }} + </message-box> + </div> + <message-box + v-if="progress.state === 'ERROR'" + dense + type="error" + transition="slide-x-transition" + > + {{ + progress.meta.errorMessage + ? progress.meta.errorMessage + : $t("celery_progress.error_message") + }} + </message-box> + <message-box + v-if="progress.state === 'SUCCESS'" + dense + type="success" + transition="slide-x-transition" + > + {{ + progress.meta.successMessage + ? progress.meta.successMessage + : $t("celery_progress.success_message") + }} + </message-box> + </v-card-text> + <v-card-actions + v-if=" + progress && + (progress.state === 'ERROR' || progress.state === 'SUCCESS') + " + > + <back-button :href="progress.meta.backUrl" text /> + <v-spacer /> + <v-btn + v-if="progress.meta.additionalButton" + :href="progress.meta.additionalButton.url" + text + color="primary" + > + <v-icon v-if="progress.meta.additionalButton.icon" left> + {{ progress.meta.additionalButton.icon }} + </v-icon> + {{ progress.meta.additionalButton.title }} + </v-btn> + </v-card-actions> + </v-card> + </v-col> + <v-col sm="0" md="1" lg="2" xl="3" /> + </v-row> +</template> + +<script> +import BackButton from "../BackButton.vue"; +import MessageBox from "../MessageBox.vue"; + +export default { + name: "CeleryProgress", + components: { BackButton, MessageBox }, + apollo: { + celeryProgressByTaskId: { + query: require("./celeryProgress.graphql"), + variables() { + return { + taskId: this.$route.params.taskId, + }; + }, + pollInterval: 1000, + }, + }, + computed: { + progress() { + return this.celeryProgressByTaskId; + }, + state() { + return this.progress ? this.progress.state : null; + }, + }, + watch: { + state(newState) { + if (newState === "SUCCESS" || newState === "ERROR") { + this.$apollo.queries.celeryProgressByTaskId.stopPolling(); + this.$apollo.mutate({ + mutation: require("./celeryProgressFetched.graphql"), + variables: { + taskId: this.$route.params.taskId, + }, + }); + } + if (newState === "SUCCESS" && this.progress.meta.redirectOnSuccessUrl) { + window.location.replace(this.progress.meta.redirectOnSuccessUrl); + // FIXME this.$router.push(this.progress.meta.redirectOnSuccessUrl); + } + }, + }, +}; +</script> diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue new file mode 100644 index 0000000000000000000000000000000000000000..2bb95431cecd66bc68789bc1bfe7c5977b68cfb6 --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue @@ -0,0 +1,53 @@ +<template> + <v-bottom-sheet :value="show" persistent hide-overlay max-width="400px"> + <v-expansion-panels accordion v-model="open"> + <v-expansion-panel> + <v-expansion-panel-header color="primary" class="white--text px-4"> + {{ + $tc("celery_progress.running_tasks", numberOfTasks, { + number: numberOfTasks, + }) + }} + </v-expansion-panel-header> + <v-expansion-panel-content> + <div class="mx-n6 mb-n4" v-if="celeryProgressByUser"> + <task-list-item + v-for="task in celeryProgressByUser" + :task="task" + :key="task.meta.taskId" + /> + </div> + </v-expansion-panel-content> + </v-expansion-panel> + </v-expansion-panels> + </v-bottom-sheet> +</template> + +<script> +import TaskListItem from "./TaskListItem.vue"; + +export default { + name: "CeleryProgressBottom", + components: { TaskListItem }, + data() { + return { open: 0 }; + }, + computed: { + show() { + return this.celeryProgressByUser && this.celeryProgressByUser.length > 0; + }, + numberOfTasks() { + if (!this.celeryProgressByUser) { + return 0; + } + return this.celeryProgressByUser.length; + }, + }, + apollo: { + celeryProgressByUser: { + query: require("./celeryProgressBottom.graphql"), + pollInterval: 1000, + }, + }, +}; +</script> diff --git a/aleksis/core/assets/components/celery_progress/TaskListItem.vue b/aleksis/core/assets/components/celery_progress/TaskListItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..a90236923904f11a37c624528b6dcfbc7b879aae --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/TaskListItem.vue @@ -0,0 +1,37 @@ +<template> + <v-list-item + :key="task.meta.taskId" + :to="{ name: 'core.celery_progress', params: { taskId: task.meta.taskId } }" + > + <v-list-item-content> + <v-list-item-title>{{ task.meta.title }}</v-list-item-title> + <v-list-item-subtitle>{{ task.meta.progressTitle }}</v-list-item-subtitle> + </v-list-item-content> + + <v-list-item-action> + <v-progress-circular + v-if="!task.complete" + color="primary" + :value="task.progress.percent" + ></v-progress-circular> + <v-icon size="32px" v-else-if="task.state === 'SUCCESS'" color="success" + >mdi-check-circle-outline</v-icon + > + <v-icon size="32px" v-else color="error">mdi-alert-circle-outline</v-icon> + </v-list-item-action> + </v-list-item> +</template> + +<script> +export default { + name: "TaskListItem", + props: { + task: { + type: Object, + required: true, + }, + }, +}; +</script> + +<style scoped></style> diff --git a/aleksis/core/assets/components/celery_progress/celeryProgress.graphql b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql new file mode 100644 index 0000000000000000000000000000000000000000..557e33517d4f3536e043ba0e64cb8c3f622741d2 --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql @@ -0,0 +1,30 @@ +query ($taskId: String!) { + celeryProgressByTaskId(taskId: $taskId) { + state + success + progress { + current + total + percent + } + complete + messages { + level + message + tag + } + meta { + title + progressTitle + errorMessage + successMessage + redirectOnSuccessUrl + backUrl + additionalButton { + title + icon + url + } + } + } +} diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5cae8f3baa46b14b4cf1031dc248d2dd7757b576 --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql @@ -0,0 +1,17 @@ +{ + celeryProgressByUser { + state + success + progress { + current + total + percent + } + complete + meta { + taskId + title + progressTitle + } + } +} diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql new file mode 100644 index 0000000000000000000000000000000000000000..b3fedc916e6a3851677f0fe7a3c322c9311a33e4 --- /dev/null +++ b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql @@ -0,0 +1,7 @@ +mutation ($taskId: String!) { + celeryProgressFetched(taskId: $taskId) { + celeryProgress { + state + } + } +} diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue index 2035cbd69d664c59d63ab95cd39571b27842d74e..3659f1f2794382722b9e7671edd831c9bc4a0911 100644 --- a/aleksis/core/assets/components/notifications/NotificationItem.vue +++ b/aleksis/core/assets/components/notifications/NotificationItem.vue @@ -3,10 +3,8 @@ :mutation="require('./markNotificationRead.graphql')" :variables="{ id: this.notification.id }" > - <template v-slot="{ mutate, loading, error }"> - <v-list-item - v-intersect="mutate" - > + <template #default="{ mutate, loading, error }"> + <v-list-item v-intersect="mutate"> <v-list-item-content> <v-list-item-title>{{ notification.title }}</v-list-item-title> @@ -22,7 +20,7 @@ <v-list-item-action v-if="notification.link"> <v-btn text :href="notification.link"> - {{ $t('notifications.more_information') }} → + {{ $t("notifications.more_information") }} → </v-btn> </v-list-item-action> @@ -35,9 +33,12 @@ </template> <script> - export default { - props: { - notification: Object, +export default { + props: { + notification: { + type: Object, + required: true, }, - } + }, +}; </script> diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue index cb42dc17af31525d437594e268139401a2186a00..9e057ef7f4093f01d072bb88b6e99f2cb13841f8 100644 --- a/aleksis/core/assets/components/notifications/NotificationList.vue +++ b/aleksis/core/assets/components/notifications/NotificationList.vue @@ -1,9 +1,9 @@ <template> <ApolloQuery :query="require('./myNotifications.graphql')" - :pollInterval="1000" + :poll-interval="1000" > - <template v-slot="{ result: { error, data }, isLoading }"> + <template #default="{ result: { error, data }, isLoading }"> <v-list two-line v-if="data && data.myNotifications.notifications.length"> <NotificationItem v-for="notification in data.myNotifications.notifications" @@ -11,17 +11,19 @@ :notification="notification" /> </v-list> - <p v-else>{{ $root.django.gettext('No notifications available yet.') }}</p> + <p v-else> + {{ $root.django.gettext("No notifications available yet.") }} + </p> </template> </ApolloQuery> </template> <script> - import NotificationItem from "./NotificationItem.vue"; +import NotificationItem from "./NotificationItem.vue"; - export default { - components: { - NotificationItem, - }, - } +export default { + components: { + NotificationItem, + }, +}; </script> diff --git a/aleksis/core/assets/components/searchSnippets.graphql b/aleksis/core/assets/components/searchSnippets.graphql new file mode 100644 index 0000000000000000000000000000000000000000..65a686624b21d4aba3d925ad7d6c48b1ab185063 --- /dev/null +++ b/aleksis/core/assets/components/searchSnippets.graphql @@ -0,0 +1,10 @@ +query search($q: String!) { + searchSnippets(query: $q, limit: 5) { + obj { + name + absoluteUrl + icon + } + text + } +} diff --git a/aleksis/core/assets/css/global.scss b/aleksis/core/assets/css/global.scss index 9c91c57ddf7fb4e56e69daa40c142bc2ed4b589b..66e731dfe7d3a47498d4356b080dc1839898d1c1 100644 --- a/aleksis/core/assets/css/global.scss +++ b/aleksis/core/assets/css/global.scss @@ -2,7 +2,14 @@ // HEADINGS // ////////////// -p, h1, h2, h3, h4, h5, h6, .card-title { +p, +h1, +h2, +h3, +h4, +h5, +h6, +.card-title { overflow-wrap: break-word; hyphens: auto; } @@ -14,3 +21,12 @@ p, h1, h2, h3, h4, h5, h6, .card-title { [v-cloak] { display: none; } + +.v-card--reveal { + bottom: 0; + opacity: 1 !important; + position: absolute; + width: 100%; + height: 100%; + overflow-y: scroll; +} diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js index 94f4131baf7181ea2a299c518f5fe942b850926d..ea18ea4dbc7cd71e42a0062da2ab80a987b11c51 100644 --- a/aleksis/core/assets/index.js +++ b/aleksis/core/assets/index.js @@ -1,4 +1,19 @@ -import '@mdi/font/css/materialdesignicons.css' +import "@mdi/font/css/materialdesignicons.css"; -import "./util" -import "./app" +import "./util"; +import "./app"; + +import CeleryProgress from "./components/celery_progress/CeleryProgress.vue"; +import About from "./components/about/About.vue"; + +window.router.addRoute({ + path: "/celery_progress/:taskId", + component: CeleryProgress, + props: true, + name: "core.celery_progress", +}); +window.router.addRoute({ + path: "/about", + component: About, + name: "core.about", +}); diff --git a/aleksis/core/assets/messages.json b/aleksis/core/assets/messages.json index 34c7dab8773a125d28657391f9e75d33d271e2b3..551b8e93f8175a097d917b906596fcd2be75d2a6 100644 --- a/aleksis/core/assets/messages.json +++ b/aleksis/core/assets/messages.json @@ -1,11 +1,40 @@ { "en": { - "notifications": { + "notifications": { "more_information": "More information", "no_notifications": "No notifications available yet." }, "alerts": { "page_cached": "This page may contain outdated information since there is no internet connection." + }, + "celery_progress": { + "progress_title": "Loading ...", + "error_message": "The operation couldn't be finished successfully.", + "success_message": "The operation has been finished successfully.", + "running_tasks": "1 running task | {number} running tasks" + }, + "actions": { + "back": "Back", + "search": "Search" + }, + "about": { + "about_aleksis": "About AlekSIS®", + "licenced_under": "Licenced under", + "licence_type": "Licence Type", + "free_software": "Free Software", + "open_source": "Open Source", + "proprietary": "Proprietary", + "licence_consists_of": "The licence consists of", + "about_aleksis_1": "This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and can be used by anyone.", + "about_aleksis_2": "AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V.", + "website_of_aleksis": "Website of AlekSIS", + "source_code": "Source Code", + "licence_information": "Licence Information", + "licence_information_1": "The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence information from third-party apps, if installed, refer to the respective components below. The licences are marked like this:", + "free_open_source_licence": "Free/Open Source Licence", + "other_licence": "Other Licence", + "full_licence_text": "Full Licence Text", + "more_information_eupl": "More information about the EUPL" } }, "de": { @@ -15,7 +44,35 @@ }, "alerts": { "page_cached": "Diese Seite enthält vielleicht veraltete Informationen, da es keine Internetverbindung gibt." + }, + "celery_progress": { + "progress_title": "Wird geladen ...", + "error_message": "Der Vorgang konnte nicht erfolgreich beendet werden.", + "success_message": "Der Vorgang wurde erfolgreich beendet.", + "running_tasks": "1 laufende Aufgabe | {number} laufende Aufgaben" + }, + "actions": { + "back": "Zurück", + "search": "Suchen" + }, + "about": { + "about_aleksis": "Über AlekSIS®", + "licenced_under": "Lizensiert unter", + "licence_type": "Lizenztyp", + "free_software": "Freie Software", + "open_source": "Open Source", + "proprietary": "Proprietär", + "licence_consists_of": "Die Lizenz besteht aus", + "about_aleksis_1": "Diese Plattform wird mit AlekSIS®, einem webbasierten Schulinformationssystem (SIS), welches für die Verwaltung und/oder Veröffentlichung von Bildungseinrichtungen verwendet werden kann. AlekSIS ist freie Software und kann von jedem benutzt werden.", + "about_aleksis_2": " AlekSIS® ist eine eingetragene Wortmarke des Open-Source-Projektes AlekSIS, vertreten durch den Teckids e.V.", + "website_of_aleksis": "Website von AlekSIS", + "source_code": "Quellcode", + "licence_information": "Lizenzinformationen", + "licence_information_1": " Der Core und die offiziellen Apps von AlekSIS sind unter der EUPL, Version 1.2 oder später, lizenziert. Für Lizenzinformationen zu Apps von Drittanbietern, wenn installiert, siehe direkt bei der jeweiligen App weiter unten auf dieser Seite. Die Lizenzen sind wie folgt markiert:", + "free_open_source_licence": "Freie/Open Source Lizenz", + "other_licence": "Andere Lizenz", + "full_licence_text": "Kompletter Lizenztext", + "more_information_eupl": "Weitere Informationen über die EUPL" } } } - diff --git a/aleksis/core/assets/util.js b/aleksis/core/assets/util.js index 1b5041b216cdae06868440b14b88f3d69acee914..a16f92bed1fc90e1930b89055278616419cf9c3b 100644 --- a/aleksis/core/assets/util.js +++ b/aleksis/core/assets/util.js @@ -1,153 +1,16 @@ -/* -commented out to see if something breaks -// Define maps between Python's strftime and Luxon's and Materialize's proprietary formats -const pythonToMomentJs = { - "%a": "EEE", - "%A": "EEEE", - "%w": "E", - "%d": "dd", - "%b": "MMM", - "%B": "MMMM", - "%m": "MM", - "%y": "yy", - "%Y": "yyyy", - "%H": "HH", - "%I": "hh", - "%p": "a", - "%M": "mm", - "%s": "ss", - "%f": "SSSSSS", - "%z": "ZZZ", - "%Z": "z", - "%U": "WW", - "%j": "ooo", - "%W": "WW", - "%u": "E", - "%G": "kkkk", - "%V": "WW", -}; - -const pythonToMaterialize = { - "%d": "dd", - "%a": "ddd", - "%A": "dddd", - "%m": "mm", - "%b": "mmm", - "%B": "mmmm", - "%y": "yy", - "%Y": "yyyy", -} - -function buildDateFormat(formatString, map) { - // Convert a Python strftime format string to another format string - for (const key in map) { - formatString = formatString.replace(key, map[key]); - } - return formatString; -} - -function initDatePicker(sel) { - // Initialize datepicker [MAT] - - // Get the date format from Django - const dateInputFormat = get_format('DATE_INPUT_FORMATS')[0] - const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs); - const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize); - - const el = $(sel).datepicker({ - format: outputFormat, - // Pull translations from Django helpers - i18n: { - months: calendarweek_i18n.month_names, - monthsShort: calendarweek_i18n.month_abbrs, - weekdays: calendarweek_i18n.day_names, - weekdaysShort: calendarweek_i18n.day_abbrs, - weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v), - - // Buttons - today: gettext('Today'), - cancel: gettext('Cancel'), - done: gettext('OK'), - }, - - // Set monday as first day of week - firstDay: get_format('FIRST_DAY_OF_WEEK'), - autoClose: true, - yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100], - }); - - // Set initial values of datepickers - $(sel).each(function () { - const currentValue = $(this).val(); - if (currentValue) { - const currentDate = luxon.DateTime.fromFormat(currentValue, inputFormat).toJSDate(); - $(this).datepicker('setDate', currentDate); - } - }); - - return el; -} - -function initTimePicker(sel) { - // Initialize timepicker [MAT] - return $(sel).timepicker({ - twelveHour: false, - autoClose: true, - i18n: { - cancel: 'Abbrechen', - clear: 'Löschen', - done: 'OK' - }, - }); -} -*/ - - -$(document).ready(function () { - - // If JS is activated, the language form will be auto-submitted - $('.language-field select').change(function () { - $(this).parents(".language-form").submit(); - }); - - // If auto-submit is activated (see above), the language submit must not be visible - $(".language-submit-p").hide(); - - // Initalize print button - $("#print").click(function () { - window.print(); - }); - - // Sync color picker - $(".jscolor").change(function () { - $("#" + $(this).data("preview")).css("color", $(this).val()); - }); - - // Initialise auto-completion for search bar - window.autocomplete = new Autocomplete({minimum_length: 2}); - window.autocomplete.setup(); - - // Initialize text collapsibles [MAT, own work] - $(".text-collapsible").addClass("closed").removeClass("opened"); - - $(".text-collapsible .open-icon").click(function (e) { - var el = $(e.target).parent(); - el.addClass("opened").removeClass("closed"); - }); - $(".text-collapsible .close-icon").click(function (e) { - var el = $(e.target).parent(); - el.addClass("closed").removeClass("opened"); - }); - - // Initialize the service worker - if ('serviceWorker' in navigator) { - console.debug("Start registration of service worker."); - navigator.serviceWorker.register('/serviceworker.js', { - scope: '/' - }).then(function () { - console.debug("Service worker has been registered."); - }).catch(function () { - console.debug("Service worker registration has failed.") - }); - } +window.addEventListener("DOMContentLoaded", function () { + // Initialize the service worker + if ("serviceWorker" in navigator) { + console.debug("Start registration of service worker."); + navigator.serviceWorker + .register("/serviceworker.js", { + scope: "/", + }) + .then(function () { + console.debug("Service worker has been registered."); + }) + .catch(function () { + console.debug("Service worker registration has failed."); + }); + } }); diff --git a/aleksis/core/migrations/0042_pdffile_empty.py b/aleksis/core/migrations/0042_pdffile_empty.py new file mode 100644 index 0000000000000000000000000000000000000000..e8055132e11f2deba85ef4ecfa24840c51ce2081 --- /dev/null +++ b/aleksis/core/migrations/0042_pdffile_empty.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.16 on 2022-11-03 11:36 + +from django.db import migrations, models +import django.utils.timezone +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0041_update_gender_choices'), + ] + + operations = [ + migrations.AlterField( + model_name='pdffile', + name='html_file', + field=models.FileField(blank=True, null=True, upload_to='pdfs/', verbose_name='Generated HTML file'), + ), + ] diff --git a/aleksis/core/migrations/0043_task_assignment_meta.py b/aleksis/core/migrations/0043_task_assignment_meta.py new file mode 100644 index 0000000000000000000000000000000000000000..cbacf70ba1e67ad0a1165aad4b6544fc0c7f0353 --- /dev/null +++ b/aleksis/core/migrations/0043_task_assignment_meta.py @@ -0,0 +1,62 @@ +# Generated by Django 3.2.15 on 2022-10-03 18:38 + +from django.db import migrations, models +import django.utils.timezone +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0042_pdffile_empty'), + ] + + operations = [ + migrations.AddField( + model_name='taskuserassignment', + name='additional_button_icon', + field=models.CharField(blank=True, max_length=255, verbose_name='Additional button icon'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='additional_button_title', + field=models.CharField(blank=True, max_length=255, verbose_name='Additional button title'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='additional_button_url', + field=models.URLField(blank=True, verbose_name='Additional button URL'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='back_url', + field=models.URLField(blank=True, verbose_name='Back URL'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='error_message', + field=models.TextField(blank=True, verbose_name='Error message'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='success_message', + field=models.TextField(blank=True, verbose_name='Success message'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='progress_title', + field=models.CharField(blank=True, max_length=255, verbose_name='Progress title'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='redirect_on_success_url', + field=models.URLField(blank=True, verbose_name='Redirect on success URL'), + ), + migrations.AddField( + model_name='taskuserassignment', + name='title', + field=models.CharField(default='Data are processed', max_length=255, verbose_name='Title'), + preserve_default=False, + ), + ] diff --git a/aleksis/core/migrations/0044_task_assignment_result_fetched.py b/aleksis/core/migrations/0044_task_assignment_result_fetched.py new file mode 100644 index 0000000000000000000000000000000000000000..e4aa43a634bb6c702861a8800f7537b978735ee0 --- /dev/null +++ b/aleksis/core/migrations/0044_task_assignment_result_fetched.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.16 on 2022-11-02 19:35 + +import django.utils.timezone +from django.db import migrations, models + +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0043_task_assignment_meta"), + ] + + operations = [ + migrations.AddField( + model_name="taskuserassignment", + name="result_fetched", + field=models.BooleanField(default=False, verbose_name="Result fetched"), + ), + ] diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index c6cee9a8c6bbefed3439eef156b685f5484e4a13..6b54f8e40a1873a9ba35379136bc0c9723889d93 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -127,7 +127,7 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase): """ # Defines a material design icon associated with this type of model - icon_ = "radio_button_unchecked" + icon_ = "radiobox-blank" site = models.ForeignKey( Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 2f603ae0d2e2813b50f2024056cb460d71b4e484..f01086c8cbe8c786ab5d2497df77e730212c7c1f 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -31,6 +31,7 @@ import jsonstore from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize from celery.result import AsyncResult +from celery_progress.backend import Progress from ckeditor.fields import RichTextField from django_celery_results.models import TaskResult from django_cte import CTEQuerySet, With @@ -176,7 +177,7 @@ class Person(ExtensibleModel): ), ] - icon_ = "person" + icon_ = "account-outline" SEX_CHOICES = [("f", _("female")), ("m", _("male")), ("x", _("other"))] @@ -504,7 +505,7 @@ class Group(SchoolTermRelatedExtensibleModel): ), ] - icon_ = "group" + icon_ = "account-multiple-outline" name = models.CharField(verbose_name=_("Long name"), max_length=255) short_name = models.CharField( @@ -1236,7 +1237,9 @@ class PDFFile(ExtensibleModel): expires_at = models.DateTimeField( verbose_name=_("File expires at"), default=_get_default_expiration ) - html_file = models.FileField(upload_to="pdfs/", verbose_name=_("Generated HTML file")) + html_file = models.FileField( + upload_to="pdfs/", verbose_name=_("Generated HTML file"), blank=True, null=True + ) file = models.FileField( upload_to="pdfs/", blank=True, null=True, verbose_name=_("Generated PDF file") ) @@ -1257,6 +1260,21 @@ class TaskUserAssignment(ExtensibleModel): get_user_model(), on_delete=models.CASCADE, verbose_name=_("Task user") ) + title = models.CharField(max_length=255, verbose_name=_("Title")) + back_url = models.URLField(verbose_name=_("Back URL"), blank=True) + progress_title = models.CharField(max_length=255, verbose_name=_("Progress title"), blank=True) + error_message = models.TextField(verbose_name=_("Error message"), blank=True) + success_message = models.TextField(verbose_name=_("Success message"), blank=True) + redirect_on_success_url = models.URLField(verbose_name=_("Redirect on success URL"), blank=True) + additional_button_title = models.CharField( + max_length=255, verbose_name=_("Additional button title"), blank=True + ) + additional_button_url = models.URLField(verbose_name=_("Additional button URL"), blank=True) + additional_button_icon = models.CharField( + max_length=255, verbose_name=_("Additional button icon"), blank=True + ) + result_fetched = models.BooleanField(default=False, verbose_name=_("Result fetched")) + @classmethod def create_for_task_id(cls, task_id: str, user: "User") -> "TaskUserAssignment": # Use get_or_create to ensure the TaskResult exists @@ -1265,6 +1283,45 @@ class TaskUserAssignment(ExtensibleModel): result, __ = TaskResult.objects.get_or_create(task_id=task_id) return cls.objects.create(task_result=result, user=user) + def get_progress(self) -> dict[str, any]: + """Get progress information for this task.""" + progress = Progress(AsyncResult(self.task_result.task_id)) + return progress.get_info() + + def get_progress_with_meta(self) -> dict[str, any]: + """Get progress information for this task.""" + progress = self.get_progress() + progress["meta"] = self + return progress + + def create_notification(self) -> Optional[Notification]: + """Create a notification for this task.""" + progress = self.get_progress() + if progress["state"] == "SUCCESS": + title = _("Background task completed successfully") + description = _("The background task '{}' has been completed successfully.").format( + self.title + ) + + elif progress["state"] == "FAILURE": + title = _("Background task failed") + description = _("The background task '{}' has failed.").format(self.title) + else: + # Task not yet finished + return + + link = reverse("task_status", args=[self.task_result.task_id]) + + notification = Notification( + sender=_("Background task"), + recipient=self.user.person, + title=title, + description=description, + link=link, + ) + notification.save() + return notification + class Meta: verbose_name = _("Task user assignment") verbose_name_plural = _("Task user assignments") diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py index e12744ff6df91c72f8ec541b8f0c0672d2ddf273..c5f2bc4d498064d687392b7e63057de50b5c1136 100644 --- a/aleksis/core/rules.py +++ b/aleksis/core/rules.py @@ -11,6 +11,7 @@ from .util.predicates import ( is_current_person, is_group_owner, is_notification_recipient, + is_own_celery_task, is_site_preference_set, ) @@ -374,3 +375,6 @@ rules.add_perm("core.edit_ical_rule", edit_ical_predicate) delete_ical_predicate = edit_ical_predicate rules.add_perm("core.delete_ical_rule", delete_ical_predicate) + +view_progress_predicate = has_person & is_own_celery_task +rules.add_perm("core.view_progress_rule", view_progress_predicate) diff --git a/aleksis/core/schema.py b/aleksis/core/schema.py deleted file mode 100644 index cb6325c0640557314ae299a1d81719d910468391..0000000000000000000000000000000000000000 --- a/aleksis/core/schema.py +++ /dev/null @@ -1,143 +0,0 @@ -from django.conf import settings -from django.utils import translation - -import graphene -from graphene import ObjectType -from graphene_django import DjangoObjectType -from graphene_django.forms.mutation import DjangoModelFormMutation - -from .forms import PersonForm -from .models import Group, Notification, Person -from .util.core_helpers import get_app_module, get_app_packages, has_person -from .util.frontend_helpers import get_language_cookie - - -class NotificationType(DjangoObjectType): - class Meta: - model = Notification - - -class PersonType(DjangoObjectType): - class Meta: - model = Person - - full_name = graphene.Field(graphene.String) - - def resolve_full_name(root: Person, info, **kwargs): - return root.full_name - - -class GroupType(DjangoObjectType): - class Meta: - model = Group - - -class LanguageType(ObjectType): - code = graphene.String(required=True) - name = graphene.String(required=True) - name_local = graphene.String(required=True) - name_translated = graphene.String(required=True) - bidi = graphene.Boolean(required=True) - cookie = graphene.String(required=True) - - -class SystemPropertiesType(graphene.ObjectType): - current_language = graphene.String(required=True) - available_languages = graphene.List(LanguageType) - - def resolve_current_language(parent, info, **kwargs): - return info.context.LANGUAGE_CODE - - def resolve_available_languages(parent, info, **kwargs): - return [ - translation.get_language_info(code) | {"cookie": get_language_cookie(code)} - for code, name in settings.LANGUAGES - ] - - -class PersonMutation(DjangoModelFormMutation): - person = graphene.Field(PersonType) - - class Meta: - form_class = PersonForm - - -class MarkNotificationReadMutation(graphene.Mutation): - class Arguments: - id = graphene.ID() # noqa - - notification = graphene.Field(NotificationType) - - @classmethod - def mutate(cls, root, info, id): # noqa - notification = Notification.objects.get(pk=id) - # FIXME permissions - notification.read = True - notification.save() - - return notification - - -class Query(graphene.ObjectType): - ping = graphene.String(default_value="pong") - - notifications = graphene.List(NotificationType) - - persons = graphene.List(PersonType) - person_by_id = graphene.Field(PersonType, id=graphene.ID()) - who_am_i = graphene.Field(PersonType) - - system_properties = graphene.Field(SystemPropertiesType) - - def resolve_notifications(root, info, **kwargs): - # FIXME do permission stuff - return Notification.objects.all() - - def resolve_persons(root, info, **kwargs): - # FIXME do permission stuff - return Person.objects.all() - - def resolve_person_by_id(root, info, id): # noqa - return Person.objects.get(pk=id) - - def resolve_who_am_i(root, info, **kwargs): - if has_person(info.context.user): - return info.context.user.person - else: - return None - - def resolve_system_properties(root, info, **kwargs): - return True - - -class Mutation(graphene.ObjectType): - update_person = PersonMutation.Field() - - mark_notification_read = MarkNotificationReadMutation.Field() - - -def build_global_schema(): - """Build global GraphQL schema from all apps.""" - query_bases = [Query] - mutation_bases = [Mutation] - - for app in get_app_packages(): - schema_mod = get_app_module(app, "schema") - if not schema_mod: - # The app does not define a schema - continue - - if AppQuery := getattr(schema_mod, "Query", None): - query_bases.append(AppQuery) - if AppMutation := getattr(schema_mod, "Mutation", None): - mutation_bases.append(AppMutation) - - # Define classes using all query/mutation classes as mixins - # cf. https://docs.graphene-python.org/projects/django/en/latest/schema/#adding-to-the-schema - GlobalQuery = type("GlobalQuery", tuple(query_bases), {}) - GlobalMutation = type("GlobalMutation", tuple(mutation_bases), {}) - - return graphene.Schema(query=GlobalQuery, mutation=GlobalMutation) - - -schema = build_global_schema() diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f5a2fa8e0723e883378541185e11d0649b18898 --- /dev/null +++ b/aleksis/core/schema/__init__.py @@ -0,0 +1,121 @@ +from django.apps import apps + +import graphene +from haystack.inputs import AutoQuery +from haystack.query import SearchQuerySet +from haystack.utils.loading import UnifiedIndex + +from ..models import Notification, Person, TaskUserAssignment +from ..util.apps import AppConfig +from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person +from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType +from .group import GroupType # noqa +from .installed_apps import AppType +from .notification import MarkNotificationReadMutation, NotificationType +from .person import PersonMutation, PersonType +from .search import SearchResultType +from .system_properties import SystemPropertiesType + + +class Query(graphene.ObjectType): + ping = graphene.String(default_value="pong") + + notifications = graphene.List(NotificationType) + + persons = graphene.List(PersonType) + person_by_id = graphene.Field(PersonType, id=graphene.ID()) + who_am_i = graphene.Field(PersonType) + + system_properties = graphene.Field(SystemPropertiesType) + installed_apps = graphene.List(AppType) + + celery_progress_by_task_id = graphene.Field(CeleryProgressType, task_id=graphene.String()) + celery_progress_by_user = graphene.List(CeleryProgressType) + + search_snippets = graphene.List( + SearchResultType, query=graphene.String(), limit=graphene.Int(required=False) + ) + + def resolve_notifications(root, info, **kwargs): + # FIXME do permission stuff + return Notification.objects.all() + + def resolve_persons(root, info, **kwargs): + # FIXME do permission stuff + return Person.objects.all() + + def resolve_person_by_id(root, info, id): # noqa + return Person.objects.get(pk=id) + + def resolve_who_am_i(root, info, **kwargs): + if has_person(info.context.user): + return info.context.user.person + else: + return None + + def resolve_system_properties(root, info, **kwargs): + return True + + def resolve_installed_apps(root, info, **kwargs): + return [app for app in apps.get_app_configs() if isinstance(app, AppConfig)] + + def resolve_celery_progress_by_task_id(root, info, task_id, **kwargs): + task = TaskUserAssignment.objects.get(task_result__task_id=task_id) + + if not info.context.user.has_perm("core.view_progress_rule", task): + return None + progress = task.get_progress_with_meta() + return progress + + def resolve_celery_progress_by_user(root, info, **kwargs): + tasks = TaskUserAssignment.objects.filter(user=info.context.user) + return [ + task.get_progress_with_meta() + for task in tasks + if task.get_progress_with_meta()["complete"] is False + ] + + def resolve_search_snippets(root, info, query, limit=-1, **kwargs): + indexed_models = UnifiedIndex().get_indexed_models() + allowed_object_ids = get_allowed_object_ids(info.context.user, indexed_models) + results = SearchQuerySet().filter(id__in=allowed_object_ids).filter(text=AutoQuery(query)) + + if limit < 0: + return results + + return results[:limit] + + +class Mutation(graphene.ObjectType): + update_person = PersonMutation.Field() + + mark_notification_read = MarkNotificationReadMutation.Field() + + celery_progress_fetched = CeleryProgressFetchedMutation.Field() + + +def build_global_schema(): + """Build global GraphQL schema from all apps.""" + query_bases = [Query] + mutation_bases = [Mutation] + + for app in get_app_packages(): + schema_mod = get_app_module(app, "schema") + if not schema_mod: + # The app does not define a schema + continue + + if AppQuery := getattr(schema_mod, "Query", None): + query_bases.append(AppQuery) + if AppMutation := getattr(schema_mod, "Mutation", None): + mutation_bases.append(AppMutation) + + # Define classes using all query/mutation classes as mixins + # cf. https://docs.graphene-python.org/projects/django/en/latest/schema/#adding-to-the-schema + GlobalQuery = type("GlobalQuery", tuple(query_bases), {}) + GlobalMutation = type("GlobalMutation", tuple(mutation_bases), {}) + + return graphene.Schema(query=GlobalQuery, mutation=GlobalMutation) + + +schema = build_global_schema() diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py new file mode 100644 index 0000000000000000000000000000000000000000..941d18f1d5e79db17a549d30dbb90f0c42310a25 --- /dev/null +++ b/aleksis/core/schema/celery_progress.py @@ -0,0 +1,94 @@ +from django.contrib.messages.constants import DEFAULT_TAGS + +import graphene +from graphene import ObjectType +from graphene_django import DjangoObjectType + +from ..models import TaskUserAssignment + + +class CeleryProgressMessage(ObjectType): + message = graphene.String(required=True) + level = graphene.Int(required=True) + tag = graphene.String(required=True) + + def resolve_message(root, info, **kwargs): + return root[1] + + def resolve_level(root, info, **kwargs): + return root[0] + + def resolve_tag(root, info, **kwargs): + return DEFAULT_TAGS.get(root[0], "info") + + +class CeleryProgressAdditionalButtonType(ObjectType): + title = graphene.String(required=True) + url = graphene.String(required=True) + icon = graphene.String() + + +class CeleryProgressMetaType(DjangoObjectType): + additional_button = graphene.Field(CeleryProgressAdditionalButtonType, required=False) + task_id = graphene.String(required=True) + + def resolve_task_id(root, info, **kwargs): + return root.task_result.task_id + + class Meta: + model = TaskUserAssignment + fields = ( + "title", + "back_url", + "progress_title", + "error_message", + "success_message", + "redirect_on_success_url", + "additional_button", + ) + + def resolve_additional_button(root, info, **kwargs): + if not root.additional_button_title or not root.additional_button_url: + return None + return { + "title": root.additional_button_title, + "url": root.additional_button_url, + "icon": root.additional_button_icon, + } + + +class CeleryProgressProgressType(ObjectType): + current = graphene.Int() + total = graphene.Int() + percent = graphene.Float() + + +class CeleryProgressType(graphene.ObjectType): + state = graphene.String() + complete = graphene.Boolean() + success = graphene.Boolean() + progress = graphene.Field(CeleryProgressProgressType) + messages = graphene.List(CeleryProgressMessage) + meta = graphene.Field(CeleryProgressMetaType) + + def resolve_messages(root, info, **kwargs): # noqa + if root["complete"] and isinstance(root["result"], list): + return root["result"] + return root["progress"].get("messages", []) + + +class CeleryProgressFetchedMutation(graphene.Mutation): + class Arguments: + task_id = graphene.String(required=True) + + celery_progress = graphene.Field(CeleryProgressType) + + def mutate(root, info, task_id, **kwargs): + task = TaskUserAssignment.objects.filter(task_result__task_id=task_id) + + if not info.context.user.has_perm("core.view_progress_rule", task): + return None + task.result_fetched = True + task.save() + progress = task.get_progress_with_meta() + return CeleryProgressFetchedMutation(celery_progress=progress) diff --git a/aleksis/core/schema/group.py b/aleksis/core/schema/group.py new file mode 100644 index 0000000000000000000000000000000000000000..327daff3dd09d54053b683ba4cefb71da8ba6e5b --- /dev/null +++ b/aleksis/core/schema/group.py @@ -0,0 +1,8 @@ +from graphene_django import DjangoObjectType + +from ..models import Group + + +class GroupType(DjangoObjectType): + class Meta: + model = Group diff --git a/aleksis/core/schema/installed_apps.py b/aleksis/core/schema/installed_apps.py new file mode 100644 index 0000000000000000000000000000000000000000..879575466ae2f06795c75c9b928fdc7f8cdb4499 --- /dev/null +++ b/aleksis/core/schema/installed_apps.py @@ -0,0 +1,58 @@ +import graphene +from graphene import ObjectType + + +class AppURLType(ObjectType): + name = graphene.String(required=True) + url = graphene.String(required=True) + + +class CopyrightType(ObjectType): + years = graphene.String(required=True) + name = graphene.String(required=True) + email = graphene.String(required=True) + + +class LicenceFlagsType(ObjectType): + isFsfLibre = graphene.Boolean(required=True) + isOsiApproved = graphene.Boolean(required=True) + + +class SubLicenceType(ObjectType): + isDeprecatedLicenseId = graphene.Boolean(default_value=False) + isFsfLibre = graphene.Boolean(default_value=False) + isOsiApproved = graphene.Boolean(default_value=False) + licenseId = graphene.String(required=True) + name = graphene.String(required=True) + referenceNumber = graphene.Int(default_value=-1) + url = graphene.String() + + +class LicenceType(ObjectType): + verbose_name = graphene.String(required=True) + flags = graphene.Field(LicenceFlagsType, required=True) + licences = graphene.List(SubLicenceType) + + +class AppType(ObjectType): + copyrights = graphene.List(CopyrightType) + licence = graphene.Field(LicenceType) + name = graphene.String(required=True) + verbose_name = graphene.String(required=True) + version = graphene.String() + urls = graphene.List(AppURLType) + + def resolve_verbose_name(root, info, **kwargs): + return root.get_name() + + def resolve_version(root, info, **kwargs): + return root.get_version() + + def resolve_licence(root, info, **kwargs): + return root.get_licence_dict() + + def resolve_urls(root, info, **kwargs): + return root.get_urls_dict() + + def resolve_copyrights(root, info, **kwargs): + return root.get_copyright_dicts() diff --git a/aleksis/core/schema/notification.py b/aleksis/core/schema/notification.py new file mode 100644 index 0000000000000000000000000000000000000000..114f92b32d9658208013144fe2d7c2d2b0157595 --- /dev/null +++ b/aleksis/core/schema/notification.py @@ -0,0 +1,25 @@ +import graphene +from graphene_django import DjangoObjectType + +from ..models import Notification + + +class NotificationType(DjangoObjectType): + class Meta: + model = Notification + + +class MarkNotificationReadMutation(graphene.Mutation): + class Arguments: + id = graphene.ID() # noqa + + notification = graphene.Field(NotificationType) + + @classmethod + def mutate(cls, root, info, id): # noqa + notification = Notification.objects.get(pk=id) + # FIXME permissions + notification.read = True + notification.save() + + return notification diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py new file mode 100644 index 0000000000000000000000000000000000000000..a3a22ce6779c7974cb61e5efeb454c1203fd8c73 --- /dev/null +++ b/aleksis/core/schema/person.py @@ -0,0 +1,23 @@ +import graphene +from graphene_django import DjangoObjectType +from graphene_django.forms.mutation import DjangoModelFormMutation + +from ..forms import PersonForm +from ..models import Person + + +class PersonType(DjangoObjectType): + class Meta: + model = Person + + full_name = graphene.Field(graphene.String) + + def resolve_full_name(root: Person, info, **kwargs): + return root.full_name + + +class PersonMutation(DjangoModelFormMutation): + person = graphene.Field(PersonType) + + class Meta: + form_class = PersonForm diff --git a/aleksis/core/schema/search.py b/aleksis/core/schema/search.py new file mode 100644 index 0000000000000000000000000000000000000000..290ac8818730e42e540c752362f5e2dcf5ea2840 --- /dev/null +++ b/aleksis/core/schema/search.py @@ -0,0 +1,32 @@ +import graphene + + +class SearchModelType(graphene.ObjectType): + absolute_url = graphene.String() + name = graphene.String() + icon = graphene.String() + + def resolve_absolute_url(root, info, **kwargs): + if hasattr(root, "get_absolute_url"): + return root.get_absolute_url() + else: + return "#!" + + def resolve_name(root, info, **kwargs): + return str(root) + + def resolve_icon(root, info, **kwargs): + return getattr(root, "icon_", "") + + +class SearchResultType(graphene.ObjectType): + app_label = graphene.String() + model_name = graphene.String() + score = graphene.Int() + obj = graphene.Field(SearchModelType) + verbose_name = graphene.String() + verbose_name_plural = graphene.String() + text = graphene.String() + + def resolve_obj(root, info, **kwargs): # noqa + return root.object diff --git a/aleksis/core/schema/system_properties.py b/aleksis/core/schema/system_properties.py new file mode 100644 index 0000000000000000000000000000000000000000..3958df762b886d5b0349b838db09e46f11ca0ffc --- /dev/null +++ b/aleksis/core/schema/system_properties.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.utils import translation + +import graphene + +from ..util.frontend_helpers import get_language_cookie + + +class LanguageType(graphene.ObjectType): + code = graphene.String(required=True) + name = graphene.String(required=True) + name_local = graphene.String(required=True) + name_translated = graphene.String(required=True) + bidi = graphene.Boolean(required=True) + cookie = graphene.String(required=True) + + +class SystemPropertiesType(graphene.ObjectType): + current_language = graphene.String(required=True) + available_languages = graphene.List(LanguageType) + + def resolve_current_language(parent, info, **kwargs): + return info.context.LANGUAGE_CODE + + def resolve_available_languages(parent, info, **kwargs): + return [ + translation.get_language_info(code) | {"cookie": get_language_cookie(code)} + for code, name in settings.LANGUAGES + ] diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 37e3df28eb49ec0eb8cb05149d06fecaeecc73ee..587dab4ee641ceda79441ae5ddcb910ce184771f 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -580,6 +580,14 @@ YARN_INSTALLED_APPS = [ "webpack-bundle-tracker@^1.6.0", "webpack-cli@^4.10.0", "vue-i18n@8", + "eslint@^8.26.0", + "eslint-plugin-vue@^9.7.0", + "eslint-webpack-plugin@^3.2.0", + "eslint-config-prettier@^8.5.0", + "stylelint@^14.14.0", + "stylelint-config-standard@^29.0.0", + "stylelint-webpack-plugin@^3.3.0", + "stylelint-config-prettier@^9.0.3", ] merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True) diff --git a/aleksis/core/static/js/copy_button.js b/aleksis/core/static/js/copy_button.js index 554f6230e4f9c8b9bb012875e31da25c4f61c2c6..c7f53e61bf5fa91a13fd24be0b01832755e955d4 100644 --- a/aleksis/core/static/js/copy_button.js +++ b/aleksis/core/static/js/copy_button.js @@ -1,16 +1,16 @@ $(".copy-button").click((e) => { - const target = $(e.currentTarget); - const input = $("#" + target.data("target")); - const copy_icon = target.children(".copy-icon-copy").first(); - const check_icon = target.children(".copy-icon-success").first(); + const target = $(e.currentTarget); + const input = $("#" + target.data("target")); + const copy_icon = target.children(".copy-icon-copy").first(); + const check_icon = target.children(".copy-icon-success").first(); - console.log("Copying to clipboard"); - navigator.clipboard.writeText(input.val()).then(r => { - check_icon.show(); - copy_icon.hide(); - setTimeout(() => { - check_icon.hide(); - copy_icon.show(); - }, 1000); - }); + console.log("Copying to clipboard"); + navigator.clipboard.writeText(input.val()).then((r) => { + check_icon.show(); + copy_icon.hide(); + setTimeout(() => { + check_icon.hide(); + copy_icon.show(); + }, 1000); + }); }); diff --git a/aleksis/core/static/js/edit_dashboard.js b/aleksis/core/static/js/edit_dashboard.js index 0cc90de60305497a682d219b121e77550d273d1d..b6e441191c118b4956e3f54cab919d9caa8fde25 100644 --- a/aleksis/core/static/js/edit_dashboard.js +++ b/aleksis/core/static/js/edit_dashboard.js @@ -1,22 +1,22 @@ function refreshOrder() { - $(".order-input").val(0); - $("#widgets > .col").each(function (index) { - const order = (index + 1) * 10; - let pk = $(this).attr("data-pk"); - let sel = $("#order-form input[value=" + pk + "].pk-input").next(); - sel.val(order); - }) + $(".order-input").val(0); + $("#widgets > .col").each(function (index) { + const order = (index + 1) * 10; + let pk = $(this).attr("data-pk"); + let sel = $("#order-form input[value=" + pk + "].pk-input").next(); + sel.val(order); + }); } $(document).ready(function () { - $('#not-used-widgets').sortable({ - group: 'widgets', - animation: 150, - onEnd: refreshOrder - }); - $('#widgets').sortable({ - group: 'widgets', - animation: 150, - onEnd: refreshOrder - }); + $("#not-used-widgets").sortable({ + group: "widgets", + animation: 150, + onEnd: refreshOrder, + }); + $("#widgets").sortable({ + group: "widgets", + animation: 150, + onEnd: refreshOrder, + }); }); diff --git a/aleksis/core/static/js/helper.js b/aleksis/core/static/js/helper.js index 844496346e451a89814ae90194db57fb67a72434..48dab95651703837a071ee39bcc3f9682d071c9b 100644 --- a/aleksis/core/static/js/helper.js +++ b/aleksis/core/static/js/helper.js @@ -1,30 +1,37 @@ function formatDate(date) { - return date.getDate() + "." + (date.getMonth() + 1) + "." + date.getFullYear(); + return ( + date.getDate() + "." + (date.getMonth() + 1) + "." + date.getFullYear() + ); } - function addZeros(i) { - if (i < 10) { - return "0" + i; - } else { - return "" + i; - } + if (i < 10) { + return "0" + i; + } else { + return "" + i; + } } function formatDateForDjango(date) { - return "" + date.getFullYear() + "/" + addZeros(date.getMonth() + 1) + "/" + addZeros(date.getDate()) + "/"; - + return ( + "" + + date.getFullYear() + + "/" + + addZeros(date.getMonth() + 1) + + "/" + + addZeros(date.getDate()) + + "/" + ); } function getNow() { - return new Date(); + return new Date(); } function getNowFormatted() { - return formatDate(getNow()); + return formatDate(getNow()); } function getJSONScript(elementId) { - return JSON.parse(document.getElementById(elementId).textContent); + return JSON.parse(document.getElementById(elementId).textContent); } - diff --git a/aleksis/core/static/js/include_ajax_live.js b/aleksis/core/static/js/include_ajax_live.js index 3a4794bad9881ebe502ea7786b8c0d2a740e65f0..0d23769c1c68114c638a8daf7217b2bc72d8948e 100644 --- a/aleksis/core/static/js/include_ajax_live.js +++ b/aleksis/core/static/js/include_ajax_live.js @@ -14,7 +14,7 @@ const setAsyncInterval = (cb, interval) => { runAsyncInterval(cb, interval, intervalIndex); return intervalIndex; } else { - throw new Error('Callback must be a function'); + throw new Error("Callback must be a function"); } }; @@ -25,11 +25,11 @@ const clearAsyncInterval = (intervalIndex) => { }; let live_load_interval = setAsyncInterval(async () => { - console.log('fetching new data'); + console.log("fetching new data"); const promise = new Promise((resolve) => { - $('#live_load').load(window.location.pathname + " #live_load"); + $("#live_load").load(window.location.pathname + " #live_load"); resolve(1); }); await promise; - console.log('data fetched successfully'); + console.log("data fetched successfully"); }, 15000); diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js index b735e2bb53d4a3c6a8f5b1edc9a9bb307214e1b4..8562b31f01d225e3f308117f78aa90ad3192f63b 100644 --- a/aleksis/core/static/js/main.js +++ b/aleksis/core/static/js/main.js @@ -1,197 +1,212 @@ // Define maps between Python's strftime and Luxon's and Materialize's proprietary formats const pythonToMomentJs = { - "%a": "EEE", - "%A": "EEEE", - "%w": "E", - "%d": "dd", - "%b": "MMM", - "%B": "MMMM", - "%m": "MM", - "%y": "yy", - "%Y": "yyyy", - "%H": "HH", - "%I": "hh", - "%p": "a", - "%M": "mm", - "%s": "ss", - "%f": "SSSSSS", - "%z": "ZZZ", - "%Z": "z", - "%U": "WW", - "%j": "ooo", - "%W": "WW", - "%u": "E", - "%G": "kkkk", - "%V": "WW", + "%a": "EEE", + "%A": "EEEE", + "%w": "E", + "%d": "dd", + "%b": "MMM", + "%B": "MMMM", + "%m": "MM", + "%y": "yy", + "%Y": "yyyy", + "%H": "HH", + "%I": "hh", + "%p": "a", + "%M": "mm", + "%s": "ss", + "%f": "SSSSSS", + "%z": "ZZZ", + "%Z": "z", + "%U": "WW", + "%j": "ooo", + "%W": "WW", + "%u": "E", + "%G": "kkkk", + "%V": "WW", }; const pythonToMaterialize = { - "%d": "dd", - "%a": "ddd", - "%A": "dddd", - "%m": "mm", - "%b": "mmm", - "%B": "mmmm", - "%y": "yy", - "%Y": "yyyy", -} + "%d": "dd", + "%a": "ddd", + "%A": "dddd", + "%m": "mm", + "%b": "mmm", + "%B": "mmmm", + "%y": "yy", + "%Y": "yyyy", +}; function buildDateFormat(formatString, map) { - // Convert a Python strftime format string to another format string - for (const key in map) { - formatString = formatString.replace(key, map[key]); - } - return formatString; + // Convert a Python strftime format string to another format string + for (const key in map) { + formatString = formatString.replace(key, map[key]); + } + return formatString; } function initDatePicker(sel) { - // Initialize datepicker [MAT] - - // Get the date format from Django - const dateInputFormat = get_format('DATE_INPUT_FORMATS')[0] - const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs); - const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize); - - const el = $(sel).datepicker({ - format: outputFormat, - // Pull translations from Django helpers - i18n: { - months: calendarweek_i18n.month_names, - monthsShort: calendarweek_i18n.month_abbrs, - weekdays: calendarweek_i18n.day_names, - weekdaysShort: calendarweek_i18n.day_abbrs, - weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v), - - // Buttons - today: gettext('Today'), - cancel: gettext('Cancel'), - done: gettext('OK'), - }, - - // Set monday as first day of week - firstDay: get_format('FIRST_DAY_OF_WEEK'), - autoClose: true, - yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100], - }); - - // Set initial values of datepickers - $(sel).each(function () { - const currentValue = $(this).val(); - if (currentValue) { - const currentDate = luxon.DateTime.fromFormat(currentValue, inputFormat).toJSDate(); - $(this).datepicker('setDate', currentDate); - } - }); - - return el; + // Initialize datepicker [MAT] + + // Get the date format from Django + const dateInputFormat = get_format("DATE_INPUT_FORMATS")[0]; + const inputFormat = buildDateFormat(dateInputFormat, pythonToMomentJs); + const outputFormat = buildDateFormat(dateInputFormat, pythonToMaterialize); + + const el = $(sel).datepicker({ + format: outputFormat, + // Pull translations from Django helpers + i18n: { + months: calendarweek_i18n.month_names, + monthsShort: calendarweek_i18n.month_abbrs, + weekdays: calendarweek_i18n.day_names, + weekdaysShort: calendarweek_i18n.day_abbrs, + weekdaysAbbrev: calendarweek_i18n.day_abbrs.map(([v]) => v), + + // Buttons + today: gettext("Today"), + cancel: gettext("Cancel"), + done: gettext("OK"), + }, + + // Set monday as first day of week + firstDay: get_format("FIRST_DAY_OF_WEEK"), + autoClose: true, + yearRange: [new Date().getFullYear() - 100, new Date().getFullYear() + 100], + }); + + // Set initial values of datepickers + $(sel).each(function () { + const currentValue = $(this).val(); + if (currentValue) { + const currentDate = luxon.DateTime.fromFormat( + currentValue, + inputFormat + ).toJSDate(); + $(this).datepicker("setDate", currentDate); + } + }); + + return el; } function initTimePicker(sel) { - // Initialize timepicker [MAT] - return $(sel).timepicker({ - twelveHour: false, - autoClose: true, - i18n: { - cancel: 'Abbrechen', - clear: 'Löschen', - done: 'OK' - }, - }); + // Initialize timepicker [MAT] + return $(sel).timepicker({ + twelveHour: false, + autoClose: true, + i18n: { + cancel: "Abbrechen", + clear: "Löschen", + done: "OK", + }, + }); } $(document).ready(function () { - $("dmc-datetime input").addClass("datepicker"); - $("[data-form-control='date']").addClass("datepicker"); - $("[data-form-control='time']").addClass("timepicker"); - - // Initialize sidenav [MAT] - $(".sidenav").sidenav(); - - // Initialize datepicker [MAT] - initDatePicker(".datepicker"); - - // Initialize timepicker [MAT] - initTimePicker(".timepicker"); - - // Initialize tooltip [MAT] - $('.tooltipped').tooltip(); - - // Initialize select [MAT] - $('select').formSelect(); - - // Initialize dropdown [MAT] - $('.dropdown-trigger').dropdown(); - $('.navbar-dropdown-trigger').dropdown({ - "coverTrigger": false, - "constrainWidth": false, - }); - - // If JS is activated, the language form will be auto-submitted - $('.language-field select').change(function () { - $(this).parents(".language-form").submit(); - }); - - // If auto-submit is activated (see above), the language submit must not be visible - $(".language-submit-p").hide(); - - // Initalize print button - $("#print").click(function () { - window.print(); - }); - - // Initialize Collapsible [MAT] - $('.collapsible').collapsible(); - - // Initialize FABs [MAT] - $('.fixed-action-btn').floatingActionButton(); - - // Initialize Modals [MAT] - $('.modal').modal(); - - // Initialize image boxes [Materialize] - $('.materialboxed').materialbox(); - - // Intialize Tabs [Materialize] - $('.tabs').tabs(); - - // Sync color picker - $(".jscolor").change(function () { - $("#" + $(this).data("preview")).css("color", $(this).val()); - }); - - // Initialise auto-completion for search bar - window.autocomplete = new Autocomplete({minimum_length: 2}); - window.autocomplete.setup(); - - // Initialize text collapsibles [MAT, own work] - $(".text-collapsible").addClass("closed").removeClass("opened"); - - $(".text-collapsible .open-icon").click(function (e) { - var el = $(e.target).parent(); - el.addClass("opened").removeClass("closed"); - }); - $(".text-collapsible .close-icon").click(function (e) { - var el = $(e.target).parent(); - el.addClass("closed").removeClass("opened"); - }); - - // Initialize the service worker - if ('serviceWorker' in navigator) { - console.debug("Start registration of service worker."); - navigator.serviceWorker.register('/serviceworker.js', { - scope: '/' - }).then(function() { - console.debug("Service worker has been registered."); - }).catch(function() { - console.debug("Service worker registration has failed.") - }); - } + $("dmc-datetime input").addClass("datepicker"); + $("[data-form-control='date']").addClass("datepicker"); + $("[data-form-control='time']").addClass("timepicker"); + + // Initialize sidenav [MAT] + $(".sidenav").sidenav(); + + // Initialize datepicker [MAT] + initDatePicker(".datepicker"); + + // Initialize timepicker [MAT] + initTimePicker(".timepicker"); + + // Initialize tooltip [MAT] + $(".tooltipped").tooltip(); + + // Initialize select [MAT] + $("select").formSelect(); + + // Initialize dropdown [MAT] + $(".dropdown-trigger").dropdown(); + $(".navbar-dropdown-trigger").dropdown({ + coverTrigger: false, + constrainWidth: false, + }); + + // If JS is activated, the language form will be auto-submitted + $(".language-field select").change(function () { + $(this).parents(".language-form").submit(); + }); + + // If auto-submit is activated (see above), the language submit must not be visible + $(".language-submit-p").hide(); + + // Initalize print button + $("#print").click(function () { + window.print(); + }); + + // Initialize Collapsible [MAT] + $(".collapsible").collapsible(); + + // Initialize FABs [MAT] + $(".fixed-action-btn").floatingActionButton(); + + // Initialize Modals [MAT] + $(".modal").modal(); + + // Initialize image boxes [Materialize] + $(".materialboxed").materialbox(); + + // Intialize Tabs [Materialize] + $(".tabs").tabs(); + + // Sync color picker + $(".jscolor").change(function () { + $("#" + $(this).data("preview")).css("color", $(this).val()); + }); + + // Initialise auto-completion for search bar + window.autocomplete = new Autocomplete({ + minimum_length: 2, + url: JSON.parse($("#search-snippet-url").text()), + }); + window.autocomplete.setup(); + + // Initialize text collapsibles [MAT, own work] + $(".text-collapsible").addClass("closed").removeClass("opened"); + + $(".text-collapsible .open-icon").click(function (e) { + var el = $(e.target).parent(); + el.addClass("opened").removeClass("closed"); + }); + $(".text-collapsible .close-icon").click(function (e) { + var el = $(e.target).parent(); + el.addClass("closed").removeClass("opened"); + }); + + // Initialize the service worker + if ("serviceWorker" in navigator) { + console.debug("Start registration of service worker."); + navigator.serviceWorker + .register("/serviceworker.js", { + scope: "/", + }) + .then(function () { + console.debug("Service worker has been registered."); + }) + .catch(function () { + console.debug("Service worker registration has failed."); + }); + } }); // Show notice if serviceworker broadcasts that the current page comes from its cache const channel = new BroadcastChannel("cache-or-not"); -channel.addEventListener("message", event => { - if ((event.data) && !($("#cache-alert").length)) { - $("main").prepend('<div id="cache-alert" class="alert warning"><p><i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>' + gettext("This page may contain outdated information since there is no internet connection.") + '</p> </div>') - } +channel.addEventListener("message", (event) => { + if (event.data && !$("#cache-alert").length) { + $("main").prepend( + '<div id="cache-alert" class="alert warning"><p><i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>' + + gettext( + "This page may contain outdated information since there is no internet connection." + ) + + "</p> </div>" + ); + } }); diff --git a/aleksis/core/static/js/multi_select.js b/aleksis/core/static/js/multi_select.js index cddf911b5f50be2217075d2c0f34191adab6f72e..105f4c13d6632224a6a88ba2a1d4047255171439 100644 --- a/aleksis/core/static/js/multi_select.js +++ b/aleksis/core/static/js/multi_select.js @@ -1,48 +1,48 @@ $(document).ready(function () { - $(".select--header-box").change(function () { - /* + $(".select--header-box").change(function () { + /* If the top checkbox is checked, all sub checkboxes should be checked, if it gets unchecked, all other ones should get unchecked. */ - if ($(this).is(":checked")) { - $(this).closest("table").find('input[name="selected_objects"]').prop({ - indeterminate: false, - checked: true, - }); - } else { - $(this).closest("table").find('input[name="selected_objects"]').prop({ - indeterminate: false, - checked: false, - }); - } - }); + if ($(this).is(":checked")) { + $(this).closest("table").find('input[name="selected_objects"]').prop({ + indeterminate: false, + checked: true, + }); + } else { + $(this).closest("table").find('input[name="selected_objects"]').prop({ + indeterminate: false, + checked: false, + }); + } + }); - $('input[name="selected_objects"]').change(function () { - /* + $('input[name="selected_objects"]').change(function () { + /* If a table checkbox changes, check the state of the other ones. If all boxes are checked the box in the header should be checked, if all boxes are unchecked the header box should be unchecked. If only some boxes are checked the top one should be inderteminate. */ - let checked = $(this).is(":checked"); - let indeterminate = false; - let table = $(this).closest("table"); - table.find('input[name="selected_objects"]').each(function () { - if ($(this).is(":checked") !== checked) { - /* Set the header box to indeterminate if the boxes are not the same */ - table.find(".select--header-box").prop({ - indeterminate: true, - }) - indeterminate = true; - return false; - } + let checked = $(this).is(":checked"); + let indeterminate = false; + let table = $(this).closest("table"); + table.find('input[name="selected_objects"]').each(function () { + if ($(this).is(":checked") !== checked) { + /* Set the header box to indeterminate if the boxes are not the same */ + table.find(".select--header-box").prop({ + indeterminate: true, }); - if (!(indeterminate)) { - /* All boxes are the same, set the header box to the same value */ - table.find(".select--header-box").prop({ - indeterminate: false, - checked: checked, - }); - } + indeterminate = true; + return false; + } }); + if (!indeterminate) { + /* All boxes are the same, set the header box to the same value */ + table.find(".select--header-box").prop({ + indeterminate: false, + checked: checked, + }); + } + }); }); diff --git a/aleksis/core/static/js/progress.js b/aleksis/core/static/js/progress.js deleted file mode 100644 index 8a97577e93d276cd62c69ead4c65b448a7631d49..0000000000000000000000000000000000000000 --- a/aleksis/core/static/js/progress.js +++ /dev/null @@ -1,84 +0,0 @@ -const OPTIONS = getJSONScript("progress_options"); - -const STYLE_CLASSES = { - 10: 'info', - 20: 'info', - 25: 'success', - 30: 'warning', - 40: 'error', -}; - -const ICONS = { - 10: 'mdi:information', - 20: 'mdi:information', - 25: 'mdi:check-circle', - 30: 'mdi:alert-outline', - 40: 'mdi:alert-octagon-outline', -}; - -function setProgress(progress) { - $("#progress-bar").css("width", progress + "%"); -} - -function renderMessageBox(level, text) { - return '<div class="alert ' + STYLE_CLASSES[level] + '"><p><i class="material-icons iconify left" data-icon="' + ICONS[level] + '"></i>' + text + '</p></div>'; -} - -function updateMessages(messages) { - const messagesBox = $("#messages"); - - // Clear container - messagesBox.html(""); - - // Render message boxes - $.each(messages, function (i, message) { - messagesBox.append(renderMessageBox(message[0], message[1])); - }); -} - -function customProgress(progressBarElement, progressBarMessageElement, progress) { - setProgress(progress.percent); - - if (progress.hasOwnProperty("messages")) { - updateMessages(progress.messages); - } -} - - -function customSuccess(progressBarElement, progressBarMessageElement, result) { - setProgress(100); - if (result) { - updateMessages(result); - } - $("#result-alert").addClass("success"); - $("#result-icon").attr("data-icon", "mdi:check-circle-outline"); - $("#result-text").text(OPTIONS.success); - $("#result-box").show(); - $("#result-button").show(); - const redirect = "redirect_on_success" in OPTIONS && OPTIONS.redirect_on_success; - if (redirect) { - window.location.replace(OPTIONS.redirect_on_success); - } -} - -function customError(progressBarElement, progressBarMessageElement, excMessage) { - setProgress(100); - if (excMessage) { - updateMessages([40, excMessage]); - } - $("#result-alert").addClass("error"); - $("#result-icon").attr("data-icon", "mdi:alert-octagon-outline"); - $("#result-text").text(OPTIONS.error); - $("#result-box").show(); -} - -$(document).ready(function () { - $("#progress-bar").removeClass("indeterminate").addClass("determinate"); - - var progressUrl = Urls["taskStatus"](OPTIONS.task_id); - CeleryProgressBar.initProgressBar(progressUrl, { - onProgress: customProgress, - onSuccess: customSuccess, - onError: customError, - }); -}); diff --git a/aleksis/core/static/js/search.js b/aleksis/core/static/js/search.js index fd121dbdfafe20c1c60ef2c793cd1f13b2a7573e..c24c79d747227ac71aa75901e8891da30956e7af 100644 --- a/aleksis/core/static/js/search.js +++ b/aleksis/core/static/js/search.js @@ -6,129 +6,129 @@ */ var Autocomplete = function (options) { - this.form_selector = options.form_selector || '.autocomplete'; - this.url = options.url || Urls.searchbarSnippets(); - this.delay = parseInt(options.delay || 300); - this.minimum_length = parseInt(options.minimum_length || 3); - this.form_elem = null; - this.query_box = null; - this.selected_element = null; + this.form_selector = options.form_selector || ".autocomplete"; + this.url = options.url; + this.delay = parseInt(options.delay || 300); + this.minimum_length = parseInt(options.minimum_length || 3); + this.form_elem = null; + this.query_box = null; + this.selected_element = null; }; Autocomplete.prototype.setup = function () { - var self = this; - - this.form_elem = $(this.form_selector); - this.query_box = this.form_elem.find('input[name=q]'); - - - $("#search-form").focusout(function (e) { - if (!$(e.relatedTarget).hasClass("search-item")) { - e.preventDefault(); - $("#search-results").remove(); - } - }); - - // Trigger the "keyup" event if input gets focused - - this.query_box.focus(function () { - self.query_box.trigger("input"); - }); - - this.query_box.on("input", () => { - console.log("Input changed, fetching again...") - var query = self.query_box.val(); - - if (query.length < self.minimum_length) { - $("#search-results").remove(); - return true; - } - - self.fetch(query); - return true; - }); - - // Watch the input box. - this.query_box.keydown(function (e) { - - if (e.which === 38) { // Keypress Up - if (!self.selected_element) { - self.setSelectedResult($("#search-collection").children().last()); - return false; - } - - let prev = self.selected_element.prev(); - if (prev.length > 0) { - self.setSelectedResult(prev); - } - return false; - } - - if (e.which === 40) { // Keypress Down - if (!self.selected_element) { - self.setSelectedResult($("#search-collection").children().first()); - return false; - } - - let next = self.selected_element.next(); - if (next.length > 0) { - self.setSelectedResult(next); - } - return false; - } - - if (self.selected_element && e.which === 13) { - e.preventDefault(); - window.location.href = self.selected_element.attr("href"); - } - }); - - // // On selecting a result, remove result box - // this.form_elem.on('click', '#search-results', function (ev) { - // $('#search-results').remove(); - // return true; - // }); - - // Disable browser's own autocomplete - // We do this here so users without JavaScript can keep it enabled - this.query_box.attr('autocomplete', 'off'); + var self = this; + + this.form_elem = $(this.form_selector); + this.query_box = this.form_elem.find("input[name=q]"); + + $("#search-form").focusout(function (e) { + if (!$(e.relatedTarget).hasClass("search-item")) { + e.preventDefault(); + $("#search-results").remove(); + } + }); + + // Trigger the "keyup" event if input gets focused + + this.query_box.focus(function () { + self.query_box.trigger("input"); + }); + + this.query_box.on("input", () => { + console.log("Input changed, fetching again..."); + var query = self.query_box.val(); + + if (query.length < self.minimum_length) { + $("#search-results").remove(); + return true; + } + + self.fetch(query); + return true; + }); + + // Watch the input box. + this.query_box.keydown(function (e) { + if (e.which === 38) { + // Keypress Up + if (!self.selected_element) { + self.setSelectedResult($("#search-collection").children().last()); + return false; + } + + let prev = self.selected_element.prev(); + if (prev.length > 0) { + self.setSelectedResult(prev); + } + return false; + } + + if (e.which === 40) { + // Keypress Down + if (!self.selected_element) { + self.setSelectedResult($("#search-collection").children().first()); + return false; + } + + let next = self.selected_element.next(); + if (next.length > 0) { + self.setSelectedResult(next); + } + return false; + } + + if (self.selected_element && e.which === 13) { + e.preventDefault(); + window.location.href = self.selected_element.attr("href"); + } + }); + + // // On selecting a result, remove result box + // this.form_elem.on('click', '#search-results', function (ev) { + // $('#search-results').remove(); + // return true; + // }); + + // Disable browser's own autocomplete + // We do this here so users without JavaScript can keep it enabled + this.query_box.attr("autocomplete", "off"); }; Autocomplete.prototype.fetch = function (query) { - var self = this; - - $.ajax({ - url: this.url, - data: { - 'q': query - }, - beforeSend: (request, settings) => { - $('#search-results').remove(); - self.setLoader(true); - }, - success: function (data) { - self.setLoader(false); - self.show_results(data); - } - }) + var self = this; + + $.ajax({ + url: this.url, + data: { + q: query, + }, + beforeSend: (request, settings) => { + $("#search-results").remove(); + self.setLoader(true); + }, + success: function (data) { + self.setLoader(false); + self.show_results(data); + }, + }); }; Autocomplete.prototype.show_results = function (data) { - $('#search-results').remove(); - var results_wrapper = $('<div id="search-results">' + data + '</div>'); - this.query_box.after(results_wrapper); - this.selected_element = null; + $("#search-results").remove(); + var results_wrapper = $('<div id="search-results">' + data + "</div>"); + this.query_box.after(results_wrapper); + this.selected_element = null; }; Autocomplete.prototype.setSelectedResult = function (element) { - if (this.selected_element) { - this.selected_element.removeClass("active"); - } - element.addClass("active"); - this.selected_element = element; - console.log("New element: ", element); + if (this.selected_element) { + this.selected_element.removeClass("active"); + } + element.addClass("active"); + this.selected_element = element; + console.log("New element: ", element); }; Autocomplete.prototype.setLoader = function (value) { - $("#search-loader").css("display", (value === true ? "block" : "none")) -} + $("#search-loader").css("display", value === true ? "block" : "none"); +}; diff --git a/aleksis/core/static/js/serviceworker.js b/aleksis/core/static/js/serviceworker.js index 16382ec00c5dadb22c63060a227451377202d363..8fa870824dd46202731afd8dd439b15aad1da4c5 100644 --- a/aleksis/core/static/js/serviceworker.js +++ b/aleksis/core/static/js/serviceworker.js @@ -1,83 +1,89 @@ - // This is the AlekSIS service worker -const CACHE = 'aleksis-cache'; +const CACHE = "aleksis-cache"; -const offlineFallbackPage = 'offline/'; +const offlineFallbackPage = "offline/"; -const channel = new BroadcastChannel('cache-or-not'); +const channel = new BroadcastChannel("cache-or-not"); var comesFromCache = false; self.addEventListener("install", function (event) { - console.log("[AlekSIS PWA] Install Event processing."); + console.log("[AlekSIS PWA] Install Event processing."); - console.log("[AlekSIS PWA] Skipping waiting on install."); - self.skipWaiting(); + console.log("[AlekSIS PWA] Skipping waiting on install."); + self.skipWaiting(); - event.waitUntil( - caches.open(CACHE).then(function (cache) { - console.log("[AlekSIS PWA] Caching pages during install."); - return cache.add(offlineFallbackPage); - }) - ); + event.waitUntil( + caches.open(CACHE).then(function (cache) { + console.log("[AlekSIS PWA] Caching pages during install."); + return cache.add(offlineFallbackPage); + }) + ); }); // Allow sw to control of current page self.addEventListener("activate", function (event) { - console.log("[AlekSIS PWA] Claiming clients for current page."); - event.waitUntil(self.clients.claim()); + console.log("[AlekSIS PWA] Claiming clients for current page."); + event.waitUntil(self.clients.claim()); }); // If any fetch fails, it will look for the request in the cache and serve it from there first self.addEventListener("fetch", function (event) { - if (event.request.method !== "GET") return; - networkFirstFetch(event); - if (comesFromCache) channel.postMessage(true); + if (event.request.method !== "GET") return; + networkFirstFetch(event); + if (comesFromCache) channel.postMessage(true); }); function networkFirstFetch(event) { - event.respondWith( - fetch(event.request) - .then(function (response) { - // If request was successful, add or update it in the cache - console.log("[AlekSIS PWA] Network request successful."); - event.waitUntil(updateCache(event.request, response.clone())); - comesFromCache = false; - return response; - }) - .catch(function (error) { - console.log("[AlekSIS PWA] Network request failed. Serving content from cache: " + error); - return fromCache(event); - }) - ); + event.respondWith( + fetch(event.request) + .then(function (response) { + // If request was successful, add or update it in the cache + console.log("[AlekSIS PWA] Network request successful."); + event.waitUntil(updateCache(event.request, response.clone())); + comesFromCache = false; + return response; + }) + .catch(function (error) { + console.log( + "[AlekSIS PWA] Network request failed. Serving content from cache: " + + error + ); + return fromCache(event); + }) + ); } function fromCache(event) { - // Check to see if you have it in the cache - // Return response - // If not in the cache, then return offline fallback page - return caches.open(CACHE).then(function (cache) { - return cache.match(event.request) - .then(function (matching) { - if (!matching || matching.status === 404) { - console.log("[AlekSIS PWA] Cache request failed. Serving offline fallback page."); - comesFromCache = false; - // Use the precached offline page as fallback - return caches.match(offlineFallbackPage); - } - comesFromCache = true; - return matching; - }); + // Check to see if you have it in the cache + // Return response + // If not in the cache, then return offline fallback page + return caches.open(CACHE).then(function (cache) { + return cache.match(event.request).then(function (matching) { + if (!matching || matching.status === 404) { + console.log( + "[AlekSIS PWA] Cache request failed. Serving offline fallback page." + ); + comesFromCache = false; + // Use the precached offline page as fallback + return caches.match(offlineFallbackPage); + } + comesFromCache = true; + return matching; }); + }); } function updateCache(request, response) { - if (response.headers.get('cache-control') && response.headers.get('cache-control').includes('no-cache')) { - return Promise.resolve(); - } else { - return caches.open(CACHE).then(function (cache) { - return cache.put(request, response); - }); - } + if ( + response.headers.get("cache-control") && + response.headers.get("cache-control").includes("no-cache") + ) { + return Promise.resolve(); + } else { + return caches.open(CACHE).then(function (cache) { + return cache.put(request, response); + }); + } } diff --git a/aleksis/core/static/print-simple.css b/aleksis/core/static/print-simple.css index f0e6536b4d835b67ab0d519de13c561953be4ea5..dfde8908dbe44e0bfa21039364455ed4b9154906 100644 --- a/aleksis/core/static/print-simple.css +++ b/aleksis/core/static/print-simple.css @@ -1,21 +1,25 @@ @page { - padding: 0; - margin: 0; + padding: 0; + margin: 0; } -table.small-print, td.small-print, th.small-print { - font-size: 10pt; +table.small-print, +td.small-print, +th.small-print { + font-size: 10pt; } tr { - border-bottom: 1px solid rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(0, 0, 0, 0.3); } -td, th { - padding: 1px; +td, +th { + padding: 1px; } -td.rotate, th.rotate { - text-align: center; - transform: rotate(-90deg); +td.rotate, +th.rotate { + text-align: center; + transform: rotate(-90deg); } diff --git a/aleksis/core/static/print.css b/aleksis/core/static/print.css index dad3abb5967e84b014769240ce000d33ced9f014..1c3e9d486a27e913671a453219c60b70b8778462 100644 --- a/aleksis/core/static/print.css +++ b/aleksis/core/static/print.css @@ -1,133 +1,139 @@ .sheet.infinite { - height: auto !important; + height: auto !important; } @page { - size: A4; - padding: 30mm; - margin: 0; + size: A4; + padding: 30mm; + margin: 0; } header { - display: block; - width: 190mm; + display: block; + width: 190mm; } - #print-header { - display: block !important; - border-bottom: 1px solid; - margin-bottom: 0; - height: 22mm; - background: white; + display: block !important; + border-bottom: 1px solid; + margin-bottom: 0; + height: 22mm; + background: white; } -header, main, footer { - margin: 0; +header, +main, +footer { + margin: 0; } #print-header .col.right-align { - padding: 15px; + padding: 15px; } .sheet { - padding: 10mm; + padding: 10mm; } - -.header-space, .footer-space { - height: 0; +.header-space, +.footer-space { + height: 0; } -.print-layout-table, .print-layout-td { - width: 190mm; - max-width: 190mm; - min-width: 190mm; +.print-layout-table, +.print-layout-td { + width: 190mm; + max-width: 190mm; + min-width: 190mm; } .print-layout-td { - padding: 0; + padding: 0; } .print-layout-table .no-border { - border: 0; + border: 0; } - footer { - margin-top: 5mm; - text-align: center; - width: 190mm; - + margin-top: 5mm; + text-align: center; + width: 190mm; } -header .row, header .col { - padding: 0 !important; - margin: 0 !important; +header .row, +header .col { + padding: 0 !important; + margin: 0 !important; } #print-logo { - height: 22mm; - width: auto; - margin-block: 0; - padding: 2mm 2mm 2mm 0; + height: 22mm; + width: auto; + margin-block: 0; + padding: 2mm 2mm 2mm 0; } .page-break { - display: block; - text-align: center; - margin: auto; - margin-top: 20px; - margin-bottom: 20px; - width: 200px; - border-top: 1px dashed; - color: darkgrey; - page-break-after: always; + display: block; + text-align: center; + margin: auto; + margin-top: 20px; + margin-bottom: 20px; + width: 200px; + border-top: 1px dashed; + color: darkgrey; + page-break-after: always; } @media print { - .header-space { - height: 35mm; - } + .header-space { + height: 35mm; + } - .footer-space { - height: 20mm - } + .footer-space { + height: 20mm; + } - header, footer { - height: 22mm; - } + header, + footer { + height: 22mm; + } - header { - position: fixed; - top: 10mm; - } + header { + position: fixed; + top: 10mm; + } - footer { - position: fixed; - bottom: 0; - } + footer { + position: fixed; + bottom: 0; + } - .page-break { - border: white; - } + .page-break { + border: white; + } } /* Some stuff for tables */ -table.small-print, td.small-print, th.small-print { - font-size: 10pt; +table.small-print, +td.small-print, +th.small-print { + font-size: 10pt; } tr { - border-bottom: 1px solid rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(0, 0, 0, 0.3); } -td, th { - padding: 1px; +td, +th { + padding: 1px; } -td.rotate, th.rotate { - text-align: center; - transform: rotate(-90deg); +td.rotate, +th.rotate { + text-align: center; + transform: rotate(-90deg); } diff --git a/aleksis/core/static/print_landscape.css b/aleksis/core/static/print_landscape.css index a348ddff6268f56a1f4fa82eac240d6c9823e14c..746968664ee7ac8e04643e97e6964cfc57796eca 100644 --- a/aleksis/core/static/print_landscape.css +++ b/aleksis/core/static/print_landscape.css @@ -1,19 +1,18 @@ @page { - size: A4 landscape; + size: A4 landscape; } header { - width: 277mm; + width: 277mm; } - -.print-layout-table, .print-layout-td { - width: 277mm; - max-width: 277mm; - min-width: 277mm; +.print-layout-table, +.print-layout-td { + width: 277mm; + max-width: 277mm; + min-width: 277mm; } - footer { - width: 277mm; + width: 277mm; } diff --git a/aleksis/core/static/public/style.scss b/aleksis/core/static/public/style.scss index 07f1881216448b3c82fb605af72b214004bf2f03..998da7726f3758310068225b7d929be51975eb14 100644 --- a/aleksis/core/static/public/style.scss +++ b/aleksis/core/static/public/style.scss @@ -4,7 +4,8 @@ background-color: $primary-color !important; } -.primary-color-text, .primary-color-text a { +.primary-color-text, +.primary-color-text a { color: $primary-color !important; } @@ -12,7 +13,8 @@ background-color: $secondary-color !important; } -.secondary-color-text, .secondary-color-text a { +.secondary-color-text, +.secondary-color-text a { color: $secondary-color !important; } @@ -29,7 +31,7 @@ rect#background { } .success { - @extend .light-green, .lighten-3 + @extend .light-green, .lighten-3; } .success-text { @@ -64,16 +66,22 @@ body { flex-direction: column; } -header, main, footer { +header, +main, +footer { margin-left: 300px; } -.without-menu header, .without-menu main, .without-menu footer { +.without-menu header, +.without-menu main, +.without-menu footer { margin-left: 0; } @media only screen and (max-width: 992px) { - header, main, footer { + header, + main, + footer { margin-left: 0; } } @@ -81,7 +89,10 @@ header, main, footer { .materialize-circle { @extend .circle; } -.collection .collection-item.avatar > .materialize-circle > .materialize-circle { +.collection + .collection-item.avatar + > .materialize-circle + > .materialize-circle { left: 0; } @@ -98,7 +109,6 @@ header, main, footer { width: auto; } - /********/ /* MAIN */ /********/ @@ -134,11 +144,18 @@ ul.sidenav li.logo > a:hover { background: none !important; } -.sidenav .collapsible-body > ul:not(.collapsible) > li.active a > i, .sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active a > i { +.sidenav .collapsible-body > ul:not(.collapsible) > li.active a > i, +.sidenav.sidenav-fixed + .collapsible-body + > ul:not(.collapsible) + > li.active + a + > i { color: #fff; } -.sidenav .collapsible-body > ul:not(.collapsible) > li.active, .sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active { +.sidenav .collapsible-body > ul:not(.collapsible) > li.active, +.sidenav.sidenav-fixed .collapsible-body > ul:not(.collapsible) > li.active { background-color: lighten($primary-color, 5%); } @@ -161,8 +178,8 @@ ul.sidenav li.logo > a:hover { border-top: 1px solid rgba(0, 0, 0, 0.14); border-bottom: 1px solid rgba(0, 0, 0, 0.14); - -webkit-transition: margin .25s ease; - transition: margin .25s ease; + -webkit-transition: margin 0.25s ease; + transition: margin 0.25s ease; } .sidenav li.search .search-wrapper input#search { @@ -215,7 +232,6 @@ div#search-results { right: 10px; } - // Footer .footer-icon { @@ -223,7 +239,6 @@ div#search-results { vertical-align: middle; } - @media only screen and (min-width: 1384px) { .footer-row-large { display: flex; @@ -280,10 +295,17 @@ h1 { h2 { font-weight: 300; - font-size: 3.0rem; + font-size: 3rem; } -p, h1, h2, h3, h4, h5, h6, .card-title { +p, +h1, +h2, +h3, +h4, +h5, +h6, +.card-title { overflow-wrap: break-word; hyphens: auto; } @@ -294,7 +316,6 @@ ul.collection .collection-item .title { font-weight: bold; } - // Forms form .row { @@ -317,7 +338,7 @@ label.chips-checkbox { height: 32px; font-size: 13px; font-weight: 500; - color: rgba(0, 0, 0, .6); + color: rgba(0, 0, 0, 0.6); line-height: 32px; padding: 0 12px; border-radius: 16px; @@ -403,24 +424,29 @@ span.badge .material-icons { font-size: 2rem; } -.btn.primary, .btn-large.primary, .btn-small.primary { +.btn.primary, +.btn-large.primary, +.btn-small.primary { background-color: rgba(0, 0, 0, 0.05) !important; color: black !important; } -.btn.primary:hover, .btn-large.primary:hover, .btn-small.primary { +.btn.primary:hover, +.btn-large.primary:hover, +.btn-small.primary { background-color: $primary-color !important; color: whitesmoke !important; } - /* Table*/ .table-container { overflow-x: auto; } -table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped tr { +table.striped > tbody > tr:nth-child(odd), +table tr.striped, +table tbody.striped tr { background-color: rgba(208, 208, 208, 0.5); } @@ -461,7 +487,9 @@ th.orderable.desc { font-size: 15px; } - header, main, footer { + header, + main, + footer { margin-left: 0; } @@ -488,11 +516,14 @@ th.orderable.desc { padding: 15px; } - main, header { + main, + header { padding: 0; } - footer, footer .footer-copyright, footer .container { + footer, + footer .footer-copyright, + footer .container { background-color: white !important; color: black !important; } @@ -501,7 +532,8 @@ th.orderable.desc { display: none; } - .footer-copyright, .footer-copyright .container { + .footer-copyright, + .footer-copyright .container { padding: 0 !important; margin: 0 !important; } @@ -513,7 +545,8 @@ th.orderable.desc { // Alerts -.alert ul, .alert p { +.alert ul, +.alert p { margin: 0; } @@ -637,7 +670,6 @@ main figure.alert { margin-bottom: 5px; } - /* Dashboard */ .card-action-badge { @@ -719,7 +751,6 @@ main figure.alert { } } - .dashboard-cards .card { display: inline-block; overflow: visible; @@ -755,14 +786,15 @@ main figure.alert { } /* Tabs with icons */ -.tabs-icons, .tabs-icons .tab, .tabs-icons a { +.tabs-icons, +.tabs-icons .tab, +.tabs-icons a { height: 72px; } .tabs-icons .tab { display: inline-flex; flex-direction: column; - } .tabs-icons .tab a { @@ -798,7 +830,8 @@ $person-logo-size: 20vh; } } -.clip-circle.no-image, .clip-circle.no-image > i.material-icons { +.clip-circle.no-image, +.clip-circle.no-image > i.material-icons { font-size: calc(#{$person-logo-size} * 0.5); color: #6f6f6f; background: #f2f2f2; @@ -817,8 +850,8 @@ $person-logo-size: 20vh; justify-content: space-between; padding: 0 1rem; > a { - position: static!important; - transform: none!important; + position: static !important; + transform: none !important; } & .nav-spacer { width: 60px; @@ -840,16 +873,17 @@ $person-logo-size: 20vh; .navbar-dropdown-trigger .clip-circle { margin: auto; - width: $navbar-height*0.75; - height: $navbar-height*0.75; + width: $navbar-height * 0.75; + height: $navbar-height * 0.75; cursor: pointer; - &.no-image, &.no-image > i.material-icons { + &.no-image, + &.no-image > i.material-icons { font-size: calc(#{$navbar-height} * 0.75 * 0.5); color: #6f6f6f; background: #f2f2f2; - line-height: $navbar-height*0.75; - width: $navbar-height*0.75; + line-height: $navbar-height * 0.75; + width: $navbar-height * 0.75; cursor: pointer; } } @@ -878,8 +912,8 @@ a.new-notification { background-color: lighten($primary-color, 30%); z-index: -1; box-shadow: 0 4px 5px 0 rgba(0, 0, 0, 0.14) inset, - 0 1px 10px 0 rgba(0, 0, 0, 0.12) inset, - 0 2px 4px -1px rgba(0, 0, 0, 0.3) inset; + 0 1px 10px 0 rgba(0, 0, 0, 0.12) inset, + 0 2px 4px -1px rgba(0, 0, 0, 0.3) inset; } .person-buttons { @@ -954,7 +988,6 @@ a.new-notification { height: 20vh; } - .application-circle img { @extend .application-circle; object-fit: cover; @@ -964,7 +997,8 @@ svg.iconify { @extend i; } -.btn .iconify.material-icons, .btn-flat .iconify.material-icons{ +.btn .iconify.material-icons, +.btn-flat .iconify.material-icons { height: $button-height; } @@ -992,7 +1026,8 @@ p.ical-description { font-weight: 300; } -.table-circle, .table-circle .materialize-circle { +.table-circle, +.table-circle .materialize-circle { height: 4em; width: 4em; } diff --git a/aleksis/core/static/public/theme.scss b/aleksis/core/static/public/theme.scss index 1b38e9bda22c7b63601a4a227d58981e2e3a8d45..3850e4c17da80967b7729bd5feebf937ee3cee5e 100644 --- a/aleksis/core/static/public/theme.scss +++ b/aleksis/core/static/public/theme.scss @@ -29,28 +29,30 @@ // 23. Collections // 24. Progress Bar - - // 1. Colors // ========================================================================== -$primary-color: adjust-color(get-colour(get-preference(theme, primary)), $alpha: 1); +$primary-color: adjust-color( + get-colour(get-preference(theme, primary)), + $alpha: 1 +); $primary-color-light: lighten($primary-color, 15%) !default; $primary-color-dark: darken($primary-color, 15%) !default; -$secondary-color: adjust-color(get-colour(get-preference(theme, secondary)), $alpha: 1); +$secondary-color: adjust-color( + get-colour(get-preference(theme, secondary)), + $alpha: 1 +); $success-color: color("green", "base") !default; $error-color: color("red", "base") !default; $link-color: color("light-blue", "darken-1") !default; - // 2. Badges // ========================================================================== $badge-bg-color: $secondary-color !default; $badge-height: 22px !default; - // 3. Buttons // ========================================================================== @@ -64,12 +66,15 @@ $button-padding: 0 16px !default; $button-radius: 2px !default; // Disabled styles -$button-disabled-background: #DFDFDF !default; -$button-disabled-color: #9F9F9F !default; +$button-disabled-background: #dfdfdf !default; +$button-disabled-color: #9f9f9f !default; // Raised buttons $button-raised-background: $secondary-color !default; -$button-raised-background-hover: lighten($button-raised-background, 5%) !default; +$button-raised-background-hover: lighten( + $button-raised-background, + 5% +) !default; $button-raised-color: #fff !default; // Large buttons @@ -81,8 +86,8 @@ $button-floating-large-size: 56px !default; // Small buttons $button-small-font-size: 13px !default; $button-small-icon-font-size: 1.2rem !default; -$button-small-height: $button-height * .9 !default; -$button-floating-small-size: $button-height * .9 !default; +$button-small-height: $button-height * 0.9 !default; +$button-floating-small-size: $button-height * 0.9 !default; // Flat buttons $button-flat-color: #343434 !default; @@ -95,7 +100,6 @@ $button-floating-color: #fff !default; $button-floating-size: 40px !default; $button-floating-radius: 50% !default; - // 4. Cards // ========================================================================== @@ -104,7 +108,6 @@ $card-bg-color: #fff !default; $card-link-color: $primary-color !default; $card-link-color-light: lighten($card-link-color, 20%) !default; - // 5. Carousel // ========================================================================== @@ -112,7 +115,6 @@ $carousel-height: 400px !default; $carousel-item-height: $carousel-height / 2 !default; $carousel-item-width: $carousel-item-height !default; - // 6. Collapsible // ========================================================================== @@ -121,7 +123,6 @@ $collapsible-line-height: $collapsible-height !default; $collapsible-header-color: #fff !default; $collapsible-border-color: #ddd !default; - // 7. Chips // ========================================================================== @@ -130,26 +131,30 @@ $chip-border-color: #9e9e9e !default; $chip-selected-color: $primary-color !default; $chip-margin: 5px !default; - // 8. Date + Time Picker // ========================================================================== $datepicker-display-font-size: 2.8rem; $datepicker-calendar-header-color: #999; -$datepicker-weekday-color: rgba(0, 0, 0, .87) !default; +$datepicker-weekday-color: rgba(0, 0, 0, 0.87) !default; $datepicker-weekday-bg: darken($secondary-color, 7%) !default; $datepicker-date-bg: $secondary-color !default; -$datepicker-year: rgba(255, 255, 255, .7) !default; -$datepicker-focus: rgba(0,0,0, .05) !default; +$datepicker-year: rgba(255, 255, 255, 0.7) !default; +$datepicker-focus: rgba(0, 0, 0, 0.05) !default; $datepicker-selected: $secondary-color !default; -$datepicker-selected-outfocus: desaturate(lighten($secondary-color, 35%), 15%) !default; -$datepicker-day-focus: transparentize(desaturate($secondary-color, 5%), .75) !default; -$datepicker-disabled-day-color: rgba(0, 0, 0, .3) !default; - -$timepicker-clock-color: rgba(0, 0, 0, .87) !default; +$datepicker-selected-outfocus: desaturate( + lighten($secondary-color, 35%), + 15% +) !default; +$datepicker-day-focus: transparentize( + desaturate($secondary-color, 5%), + 0.75 +) !default; +$datepicker-disabled-day-color: rgba(0, 0, 0, 0.3) !default; + +$timepicker-clock-color: rgba(0, 0, 0, 0.87) !default; $timepicker-clock-plate-bg: #eee !default; - // 9. Dropdown // ========================================================================== @@ -158,7 +163,6 @@ $dropdown-hover-bg-color: #eee !default; $dropdown-color: $secondary-color !default; $dropdown-item-height: 50px !default; - // 10. Forms // ========================================================================== @@ -174,8 +178,8 @@ $input-font-size: 16px !default; $input-margin-bottom: 8px; $input-margin: 0 0 $input-margin-bottom 0 !default; $input-padding: 0 !default; -$label-font-size: .8rem !default; -$input-disabled-color: rgba(0,0,0, .42) !default; +$label-font-size: 0.8rem !default; +$input-disabled-color: rgba(0, 0, 0, 0.42) !default; $input-disabled-solid-color: #949494 !default; $input-disabled-border: 1px dotted $input-disabled-color !default; $input-invalid-border: 1px solid $input-error-color !default; @@ -194,23 +198,25 @@ $track-height: 3px !default; // Select $select-border: 1px solid #f2f2f2 !default; -$select-background: rgba(255, 255, 255, 0.90) !default; +$select-background: rgba(255, 255, 255, 0.9) !default; $select-focus: 1px solid lighten($secondary-color, 47%) !default; -$select-option-hover: rgba(0,0,0,.08) !default; -$select-option-focus: rgba(0,0,0,.08) !default; -$select-option-selected: rgba(0,0,0,.03) !default; +$select-option-hover: rgba(0, 0, 0, 0.08) !default; +$select-option-focus: rgba(0, 0, 0, 0.08) !default; +$select-option-selected: rgba(0, 0, 0, 0.03) !default; $select-padding: 5px !default; $select-radius: 2px !default; -$select-disabled-color: rgba(0,0,0,.3) !default; +$select-disabled-color: rgba(0, 0, 0, 0.3) !default; // Switches $switch-bg-color: $secondary-color !default; -$switch-checked-lever-bg: desaturate(lighten($switch-bg-color, 25%), 25%) !default; -$switch-unchecked-bg: #F1F1F1 !default; -$switch-unchecked-lever-bg: rgba(0,0,0,.38) !default; +$switch-checked-lever-bg: desaturate( + lighten($switch-bg-color, 25%), + 25% +) !default; +$switch-unchecked-bg: #f1f1f1 !default; +$switch-unchecked-lever-bg: rgba(0, 0, 0, 0.38) !default; $switch-radius: 15px !default; - // 11. Global // ========================================================================== @@ -229,15 +235,13 @@ $small-and-down: "only screen and (max-width : #{$small-screen})" !default; $medium-and-down: "only screen and (max-width : #{$medium-screen})" !default; $medium-only: "only screen and (min-width : #{$small-screen-up}) and (max-width : #{$medium-screen})" !default; - // 12. Grid // ========================================================================== $num-cols: 12 !default; $gutter-width: 1.5rem !default; $element-top-margin: $gutter-width/3 !default; -$element-bottom-margin: ($gutter-width*2)/3 !default; - +$element-bottom-margin: ($gutter-width * 2)/3 !default; // 13. Navigation Bar // ========================================================================== @@ -255,27 +259,24 @@ $navbar-brand-font-size: 2.1rem !default; $sidenav-width: 300px !default; $sidenav-font-size: 14px !default; -$sidenav-font-color: rgba(0,0,0,.87) !default; +$sidenav-font-color: rgba(0, 0, 0, 0.87) !default; $sidenav-bg-color: #fff !default; $sidenav-padding: 16px !default; $sidenav-item-height: 48px !default; $sidenav-line-height: $sidenav-item-height !default; - // 15. Photo Slider // ========================================================================== -$slider-bg-color: color('grey', 'base') !default; -$slider-bg-color-light: color('grey', 'lighten-2') !default; -$slider-indicator-color: color('green', 'base') !default; - +$slider-bg-color: color("grey", "base") !default; +$slider-bg-color-light: color("grey", "lighten-2") !default; +$slider-indicator-color: color("green", "base") !default; // 16. Spinners | Loaders // ========================================================================== $spinner-default-color: $secondary-color !default; - // 17. Tabs // ========================================================================== @@ -283,14 +284,12 @@ $tabs-underline-color: $primary-color-light !default; $tabs-text-color: $primary-color !default; $tabs-bg-color: #fff !default; - // 18. Tables // ========================================================================== -$table-border-color: rgba(0,0,0,.12) !default; +$table-border-color: rgba(0, 0, 0, 0.12) !default; $table-striped-color: rgba(242, 242, 242, 0.5) !default; - // 19. Toasts // ========================================================================== @@ -299,11 +298,11 @@ $toast-color: #323232 !default; $toast-text-color: #fff !default; $toast-action-color: #eeff41; - // 20. Typography // ========================================================================== -$font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !default; +$font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, + Ubuntu, Cantarell, "Helvetica Neue", sans-serif !default; $off-black: rgba(0, 0, 0, 0.87) !default; // Header Styles $h1-fontsize: 4.2rem !default; @@ -313,24 +312,21 @@ $h4-fontsize: 2.28rem !default; $h5-fontsize: 1.64rem !default; $h6-fontsize: 1.15rem !default; - // 21. Footer // ========================================================================== $footer-font-color: #fff !default; $footer-bg-color: $primary-color !default; -$footer-copyright-font-color: rgba(255,255,255,.8) !default; -$footer-copyright-bg-color: rgba(51,51,51,.08) !default; - +$footer-copyright-font-color: rgba(255, 255, 255, 0.8) !default; +$footer-copyright-bg-color: rgba(51, 51, 51, 0.08) !default; // 22. Flow Text // ========================================================================== -$range : $large-screen - $small-screen !default; +$range: $large-screen - $small-screen !default; $intervals: 20 !default; $interval-size: $range / $intervals !default; - // 23. Collections // ========================================================================== @@ -342,7 +338,6 @@ $collection-hover-bg-color: #ddd !default; $collection-link-color: $secondary-color !default; $collection-line-height: 1.5rem !default; - // 24. Progress Bar // ========================================================================== diff --git a/aleksis/core/tasks.py b/aleksis/core/tasks.py index 7b7e529b3a1fa21c5a6620170296d9ee1eb777ec..8306203ee66228ef264b7d06f15e6eb46690b5ab 100644 --- a/aleksis/core/tasks.py +++ b/aleksis/core/tasks.py @@ -1,3 +1,4 @@ +import time from datetime import timedelta from django.conf import settings @@ -55,3 +56,16 @@ def clear_oauth_tokens(): def send_notifications(): """Send due notifications to users.""" _send_due_notifications() + + +@app.task +def send_notification_for_done_task(task_id): + """Send a notification for a done task.""" + from aleksis.core.models import TaskUserAssignment + + # Wait five seconds to ensure that the client has received the final status + time.sleep(5) + + assignment = TaskUserAssignment.objects.get(task_result__task_id=task_id) + if not assignment.result_fetched: + assignment.create_notification() diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index 3158986ac5ed921244b43663e64c2fe9d728cd32..582a709d2f3c0a6935ebb218de7ae117336fa7bc 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -239,6 +239,8 @@ {% include_js "materialize" %} {% include_js "sortablejs" %} {% include_js "jquery-sortablejs" %} +{% url "searchbar_snippets" as search_snippets_url %} +{{ search_snippets_url|json_script:"search-snippet-url" }} <script type="text/javascript" src="{% static 'js/search.js' %}"></script> <script type="text/javascript" src="{% static 'js/main.js' %}"></script> </body> diff --git a/aleksis/core/templates/core/pages/about.html b/aleksis/core/templates/core/pages/about.html index 6c44d90cbfe51df457f2bd09f600dfb676b7b97d..3489a50e2395309b078a0c1b2fa053b6a728fac6 100644 --- a/aleksis/core/templates/core/pages/about.html +++ b/aleksis/core/templates/core/pages/about.html @@ -1,5 +1,5 @@ {# -*- engine:django -*- #} -{% extends "core/base.html" %} +{% extends "core/vue_base.html" %} {% load i18n %} @@ -7,121 +7,5 @@ {% block page_title %}{% blocktrans %}AlekSIS® – The Free School Information System{% endblocktrans %}{% endblock %} {% block content %} - - <div class="row"> - <div class="col s12"> - <div class="card"> - <div class="card-content"> - <span class="card-title">{% blocktrans %}About AlekSIS{% endblocktrans %}</span> - <p> - {% blocktrans %} - This platform is powered by AlekSIS®, a web-based school information system (SIS) which can be used - to manage and/or publish organisational artifacts of educational institutions. AlekSIS is free software and - can be used by anyone. - {% endblocktrans %} - </p> - <p> - {% blocktrans %} - AlekSIS® is a registered trademark of the AlekSIS open source project, represented by Teckids e.V. - {% endblocktrans %} - </p> - </div> - <div class="card-action"> - <a class="" href="https://aleksis.org/">{% trans "Website of AlekSIS" %}</a> - <a class="" href="https://edugit.org/AlekSIS/">{% trans "Source code" %}</a> - </div> - </div> - </div> - </div> - <div class="row"> - <div class="col s12"> - <div class="card"> - <div class="card-content"> - <span class="card-title">{% trans "Licence information" %}</span> - <p> - {% blocktrans %} - The core and the official apps of AlekSIS are licenced under the EUPL, version 1.2 or later. For licence - information from third-party apps, if installed, refer to the respective components below. The - licences are marked like this: - {% endblocktrans %} - </p> - <br/> - <p> - <span class="chip green white-text">{% trans "Free/Open Source Licence" %}</span> - <span class="chip orange white-text">{% trans "Other Licence" %}</span> - </p> - </div> - <div class="card-action"> - <a href="https://eupl.eu">{% trans "Full licence text" %}</a> - <a href="https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers">{% trans "More information about the EUPL" %}</a> - </div> - </div> - </div> - </div> - - <div class="row"> - {% for app_config in app_configs %} - <div class="col s12 m12 l6"> - <div class="card " id="{{ app_config.name }}"> - <div class="card-content"> - {% if app_config.get_licence.1.isFsfLibre %} - <span class="chip green white-text right">Free Software</span> - {% elif app_config.get_licence.1.isOsiApproved %} - <span class="chip green white-text right">Open Source</span> - {% endif %} - - <span class="card-title">{{ app_config.get_name }} <small>{{ app_config.get_version }}</small></span> - - {% if app_config.get_copyright %} - <p> - {% for holder in app_config.get_copyright %} - Copyright © {{ holder.0 }} - - {% if holder.2 %} - <a href="mailto:{{ holder.2 }}">{{ holder.1 }}</a> - {% else %} - {{ holder.1 }} - {% endif %} - - <br/> - {% endfor %} - </p> - <br/> - {% endif %} - - {% if app_config.get_licence %} - {% with licence=app_config.get_licence %} - <p> - {% blocktrans with licence=licence.0 %} - This app is licenced under {{ licence }}. - {% endblocktrans %} - </p> - <br/> - <p> - {% for l in licence.2 %} - <a class="chip white-text {% if l.isOsiApproved or l.isFsfLibre %}green{% else %}orange{% endif %}" - href="{{ l.url }}"> - {{ l.name }} - </a> - {% endfor %} - </p> - {% endwith %} - {% endif %} - </div> - {% if app_config.get_urls %} - <div class="card-action"> - {% for url_name, url in app_config.get_urls.items %} - <a href="{{ url }}">{{ url_name }}</a> - {% endfor %} - </div> - {% endif %} - </div> - </div> - {% if forloop.counter|divisibleby:2 %} - </div> - <div class="row"> - {% endif %} - {% endfor %} - </div> - + <router-view></router-view> {% endblock %} diff --git a/aleksis/core/templates/core/pages/progress.html b/aleksis/core/templates/core/pages/progress.html index dc12869c8130a26bf2d5d96f1d30e8a83444bd61..82dc6506058e0b0e7da785a0161c1f45585634b7 100644 --- a/aleksis/core/templates/core/pages/progress.html +++ b/aleksis/core/templates/core/pages/progress.html @@ -1,63 +1,10 @@ -{% extends "core/base.html" %} +{% extends "core/vue_base.html" %} {% load i18n static %} {% block browser_title %} - {{ title }} -{% endblock %} -{% block page_title %} - {{ title }} + {% trans "Progress" %} {% endblock %} {% block content %} - - <div class="container"> - <div class="row"> - <div class="progress center"> - <div class="indeterminate" style="width: 0;" id="progress-bar"></div> - </div> - <h6 class="center"> - {{ progress.title }} - </h6> - </div> - <div class="row"> - <noscript> - <div class="alert warning"> - <p> - <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i> - {% blocktrans %} - Without activated JavaScript the progress status can't be updated. - {% endblocktrans %} - </p> - </div> - </noscript> - - <div id="messages"></div> - - <div id="result-box" style="display: none;"> - <div class="alert" id="result-alert"> - <div> - <i class="material-icons iconify left" id="result-icon" data-icon="mdi:check-circle-outline"></i> - <p id="result-text"></p> - </div> - </div> - - {% url "index" as index_url %} - <a class="btn waves-effect waves-light" href="{{ back_url|default:index_url }}"> - <i class="material-icons iconify left" data-icon="mdi:arrow-left"></i> - {% trans "Go back" %} - </a> - {% if additional_button %} - <a class="btn waves-effect waves-light" href="{{ additional_button.href }}" id="result-button" style="display: none;"> - <i class="material-icons iconify left" data-icon="{{ additional_button.icon|default:"" }}"></i> - {{ additional_button.caption }} - </a> - {% endif %} - </div> - </div> - </div> - - {{ progress|json_script:"progress_options" }} - <script src="{% static "js/helper.js" %}"></script> - <script src="{% static "celery_progress/celery_progress.js" %}"></script> - <script src="{% static "js/progress.js" %}"></script> + <router-view></router-view> {% endblock %} diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html index 3d8f6a7bf10553c6aed4d2dde2fbe1740b7651fc..85abd8e79d4752aa79e771c5d251dfb1fe450f53 100644 --- a/aleksis/core/templates/core/vue_base.html +++ b/aleksis/core/templates/core/vue_base.html @@ -19,17 +19,12 @@ </title> {# CSS #} - {# FIXME ↓ #} - {# {% include_css "material-design-icons" %}#} {% include_css "Roboto100" %} {% include_css "Roboto300" %} {% include_css "Roboto400" %} {% include_css "Roboto500" %} {% include_css "Roboto700" %} {% include_css "Roboto900" %} - {# <link rel="stylesheet" href="{% sass_src 'public/style.scss' %}">#} - - <!-- FIXME: Find a way to use SCSS!!! --> {# Add JS URL resolver #} <script src="{% url "js_reverse" %}" type="text/javascript"></script> @@ -61,9 +56,6 @@ <script type="text/javascript" src="{% url 'config.js' %}"></script> {% include_js "iconify" %} - {# Include jQuery early to provide $(document).ready #} - {% include_js "jQuery" %} - {% block extra_head %}{% endblock %} </head> <body {% if no_menu %}class="without-menu"{% endif %}> @@ -82,7 +74,7 @@ {% has_perm 'core.search_rule' user as search %} {% if search %} <v-list-item class="search"> - <sidenav-search action="{% url "haystack_search" %}" placeholder="{% trans "Search" %}"></sidenav-search> + <sidenav-search action="{% url "haystack_search" %}"></sidenav-search> </v-list-item> {% endif %} {% include "core/partials/vue_sidenav.html" %} @@ -168,6 +160,8 @@ </v-container> </v-main> + <celery-progress-bottom /> + <v-footer app absolute inset dark class="pa-0 d-flex" color="primary lighten-1"> <v-card flat diff --git a/aleksis/core/templates/search/searchbar_snippet.html b/aleksis/core/templates/search/searchbar_snippet.html index d2a401c4f874d6a2c2a71c5b122eb3879cd83d57..d0ec278c2b232f701e6231e337c14c10c0096839 100644 --- a/aleksis/core/templates/search/searchbar_snippet.html +++ b/aleksis/core/templates/search/searchbar_snippet.html @@ -1,4 +1,4 @@ <a href="{{ result.object.get_absolute_url|default:"#" }}" class="collection-item search-item"> {{ result.object }} - <i class="material-icons secondary-content search-result-icon" data-icon="mdi:{{ result.object.icon_ }}"></i> + <i class="material-icons secondary-content search-result-icon iconify" data-icon="mdi:{{ result.object.icon_ }}"></i> </a> diff --git a/aleksis/core/templates/templated_email/email.css b/aleksis/core/templates/templated_email/email.css index 8cd112624c0c0b78f663027841895f4e07ce608b..465da3dd073bf681eb3146a077ea17d46f643fca 100644 --- a/aleksis/core/templates/templated_email/email.css +++ b/aleksis/core/templates/templated_email/email.css @@ -1,41 +1,45 @@ body { - line-height: 1.5; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; - font-weight: normal; - color: rgba(0, 0, 0, 0.87); - display: flex; - justify-content: center; - align-items: center; + line-height: 1.5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-weight: normal; + color: rgba(0, 0, 0, 0.87); + display: flex; + justify-content: center; + align-items: center; } -table, tr { - width: 100%; +table, +tr { + width: 100%; } .main { - max-width: 700px; - box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2); - -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2); - -webkit-transition: -webkit-box-shadow .25s; - transition: -webkit-box-shadow .25s; - transition: box-shadow .25s; - transition: box-shadow .25s, -webkit-box-shadow .25s; - border-radius: 2px; - background-color: #fff; - margin: 30px; - padding: 20px; + max-width: 700px; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2); + -webkit-box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 3px 1px -2px rgba(0, 0, 0, 0.12), 0 1px 5px 0 rgba(0, 0, 0, 0.2); + -webkit-transition: -webkit-box-shadow 0.25s; + transition: -webkit-box-shadow 0.25s; + transition: box-shadow 0.25s; + transition: box-shadow 0.25s, -webkit-box-shadow 0.25s; + border-radius: 2px; + background-color: #fff; + margin: 30px; + padding: 20px; } .first th { - border-bottom: 1px solid; + border-bottom: 1px solid; } - -td, th { - padding-left: 5px; - padding-right: 5px; +td, +th { + padding-left: 5px; + padding-right: 5px; } .align-center { - text-align: center; + text-align: center; } diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py index 5b3898dd835506ade932ebccf30e4cf0de15486e..49e359872784773836558c13081ae1290b4477c5 100644 --- a/aleksis/core/util/apps.py +++ b/aleksis/core/util/apps.py @@ -128,12 +128,28 @@ class AppConfig(django.apps.AppConfig): # We could not find a valid licence return ("Unknown", [default_dict]) + @classmethod + def get_licence_dict(cls): + """Get licence information of application package.""" + licence = cls.get_licence() + return { + "verbose_name": licence[0], + "flags": licence[1], + "licences": licence[2], + } + @classmethod def get_urls(cls): """Get list of URLs for this application package.""" return getattr(cls, "urls", {}) # TODO Try getting from distribution if not set + @classmethod + def get_urls_dict(cls): + """Get list of URLs for this application package.""" + urls = cls.get_urls() + return [{"name": key, "url": value} for key, value in urls.items()] + @classmethod def get_copyright(cls) -> Sequence[tuple[str, str, str]]: """Get copyright information tuples for application package.""" @@ -155,6 +171,12 @@ class AppConfig(django.apps.AppConfig): return copyrights_processed # TODO Try getting from distribution if not set + @classmethod + def get_copyright_dicts(cls): + """Get copyright information dictionaries for application package.""" + infos = cls.get_copyright() + return [{"years": info[0], "name": info[1], "email": info[2]} for info in infos] + def preference_updated( self, sender: Any, diff --git a/aleksis/core/util/celery_progress.py b/aleksis/core/util/celery_progress.py index 91b5b7168f215da509c71806f137483a422872d3..d377d9d334c0243af210cc68e3474aeaea748b2e 100644 --- a/aleksis/core/util/celery_progress.py +++ b/aleksis/core/util/celery_progress.py @@ -5,12 +5,13 @@ from typing import Callable, Generator, Iterable, Optional, Sequence, Union from django.apps import apps from django.contrib import messages from django.http import HttpRequest -from django.shortcuts import render +from django.shortcuts import redirect from celery.result import AsyncResult from celery_progress.backend import PROGRESS_STATE, AbstractProgressRecorder from ..celery import app +from ..tasks import send_notification_for_done_task class ProgressRecorder(AbstractProgressRecorder): @@ -156,6 +157,11 @@ def recorded_task(orig: Optional[Callable] = None, **kwargs) -> Union[Callable, def _inject_recorder(task, *args, **kwargs): recorder = ProgressRecorder(task) orig(*args, **kwargs, recorder=recorder) + + # Start notification task to ensure + # that the user is informed about the result in any case + send_notification_for_done_task.delay(task.request.id) + return recorder._messages # Force bind to True because _inject_recorder needs the Task object @@ -203,22 +209,15 @@ def render_progress_page( TaskUserAssignment = apps.get_model("core", "TaskUserAssignment") assignment = TaskUserAssignment.create_for_task_id(task_result.task_id, request.user) - # Prepare context for progress page - context["title"] = title - context["back_url"] = back_url - context["progress"] = { - "task_id": task_result.task_id, - "title": progress_title, - "success": success_message, - "error": error_message, - "redirect_on_success": redirect_on_success_url, - } - - if button_url and button_title: - context["additional_button"] = { - "href": button_url, - "caption": button_title, - "icon": button_icon, - } - - return render(request, "core/pages/progress.html", context) + assignment.title = title + assignment.back_url = back_url or "" + assignment.progress_title = progress_title or "" + assignment.error_message = error_message or "" + assignment.success_message = success_message or "" + assignment.redirect_on_success_url = redirect_on_success_url or "" + assignment.additional_button_title = button_title or "" + assignment.additional_button_url = button_url or "" + assignment.additional_button_icon = button_icon or "" + assignment.save() + + return redirect("task_status", task_id=task_result.task_id) diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py index 8721d8c3167ba8a01fd250a0967f56af495bdf78..66b5e1a9fdf58d4630651e1795167df001f42567 100644 --- a/aleksis/core/util/pdf.py +++ b/aleksis/core/util/pdf.py @@ -88,10 +88,16 @@ def process_context_for_pdf(context: Optional[dict] = None, request: Optional[Ht def generate_pdf_from_html( - html: str, request: Optional[HttpRequest] = None + html: str, request: Optional[HttpRequest] = None, file_object: Optional[PDFFile] = None ) -> Tuple[PDFFile, AsyncResult]: """Start a PDF generation task and return the matching file object and Celery result.""" - file_object = PDFFile.objects.create(html_file=ContentFile(html.encode(), name="source.html")) + html_file = ContentFile(html.encode(), name="source.html") + + # In some cases, the file object is already created (to get a redirect URL for the PDF) + if not file_object: + file_object = PDFFile.objects.create() + file_object.html_file = html_file + file_object.save() # As this method may be run in background and there is no request available, # we have to use a predefined URL from settings then @@ -110,6 +116,7 @@ def generate_pdf_from_template( context: Optional[dict] = None, request: Optional[HttpRequest] = None, render_method: Optional[Callable] = None, + file_object: Optional[PDFFile] = None, ) -> Tuple[PDFFile, AsyncResult]: """Start a PDF generation task and return the matching file object and Celery result.""" processed_context = process_context_for_pdf(context, request) @@ -119,7 +126,7 @@ def generate_pdf_from_template( else: html_template = render_to_string(template_name, processed_context, request) - return generate_pdf_from_html(html_template, request) + return generate_pdf_from_html(html_template, request, file_object=file_object) def render_pdf( @@ -147,7 +154,7 @@ def render_pdf( back_url=context.get("back_url", reverse("index")), button_title=_("Download PDF"), button_url=redirect_url, - button_icon="picture_as_pdf", + button_icon="mdi-file-pdf-box", ) diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py index 5ba4271c08a4244ba5f359e9c46e01eff6c6d112..9996fa4655c75d525d7d6a305d3f1fc839323889 100644 --- a/aleksis/core/util/predicates.py +++ b/aleksis/core/util/predicates.py @@ -160,3 +160,9 @@ def has_activated_2fa(user: User) -> bool: def is_assigned_to_current_person(user: User, obj: Model) -> bool: """Check if the object is assigned to the current person.""" return getattr(obj, "person", None) == user.person + + +@predicate +def is_own_celery_task(user: User, obj: Model) -> bool: + """Check if the celery task is owned by the current user.""" + return obj.user == user diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 6512782b6e970bde7a236bd480359883e27cdb7e..e55a58b9c88e62ba3c9716300e8416e86e6d6eec 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -40,7 +40,6 @@ from allauth.account.utils import has_verified_email, send_email_confirmation from allauth.account.views import PasswordChangeView, PasswordResetView, SignupView from allauth.socialaccount.adapter import get_adapter from allauth.socialaccount.models import SocialAccount -from celery_progress.views import get_progress from django_celery_results.models import TaskResult from django_filters.views import FilterView from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView @@ -1338,17 +1337,15 @@ class RedirectToPDFFile(SingleObjectMixin, View): return redirect(file_object.file.url) -class CeleryProgressView(View): +class CeleryProgressView(PermissionRequiredMixin, DetailView): """Wrap celery-progress view to check permissions before.""" - def get(self, request: HttpRequest, task_id: str, *args, **kwargs) -> HttpResponse: - if request.user.is_anonymous: - raise Http404(_("The requested task does not exist or is not accessible")) - if not TaskUserAssignment.objects.filter( - task_result__task_id=task_id, user=request.user - ).exists(): - raise Http404(_("The requested task does not exist or is not accessible")) - return get_progress(request, task_id, *args, **kwargs) + template_name = "core/pages/progress.html" + permission_required = "core.view_progress_rule" + + def get_object(self, queryset=None): + task_id = self.kwargs.get("task_id") + return TaskUserAssignment.objects.get(task_result__task_id=task_id) class CustomPasswordChangeView(LoginRequiredMixin, PermissionRequiredMixin, PasswordChangeView): diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js index f33cc60252c0c11bfeacb4c304db7c396a9530ad..8f327cbe55a6dba6b7ae5f104a89f4da46ddf711 100644 --- a/aleksis/core/webpack.config.js +++ b/aleksis/core/webpack.config.js @@ -1,50 +1,58 @@ -const fs = require('fs'); -const path = require('path'); -const webpack = require('webpack'); -const BundleTracker = require('webpack-bundle-tracker'); -const { VueLoaderPlugin } = require('vue-loader'); +const fs = require("fs"); +const path = require("path"); +const webpack = require("webpack"); +const BundleTracker = require("webpack-bundle-tracker"); +const { VueLoaderPlugin } = require("vue-loader"); +const ESLintPlugin = require("eslint-webpack-plugin"); +const StyleLintPlugin = require("stylelint-webpack-plugin"); module.exports = { context: __dirname, - entry: JSON.parse(fs.readFileSync('./webpack-entrypoints.json')), + entry: JSON.parse(fs.readFileSync("./webpack-entrypoints.json")), output: { - path: path.resolve('./webpack_bundles/'), + path: path.resolve("./webpack_bundles/"), filename: "[name]-[hash].js", chunkFilename: "[id]-[chunkhash].js", }, plugins: [ - new BundleTracker({filename: './webpack-stats.json'}), + new BundleTracker({ filename: "./webpack-stats.json" }), new VueLoaderPlugin(), + new ESLintPlugin({ + extensions: ["js", "vue"], + }), + new StyleLintPlugin({ + files: ["assets/**/*.{vue,htm,html,css,sss,less,scss,sass}"], + }), ], module: { rules: [ { test: /\.vue$/, use: { - loader: 'vue-loader', + loader: "vue-loader", options: { transpileOptions: { transforms: { - dangerousTaggedTemplateString: true - } - } - } + dangerousTaggedTemplateString: true, + }, + }, + }, }, }, { test: /\.(css)$/, - use: ['vue-style-loader', 'css-loader'], + use: ["vue-style-loader", "css-loader"], }, { test: /\.scss$/, use: [ - 'vue-style-loader', - 'css-loader', + "vue-style-loader", + "css-loader", { - loader: 'sass-loader', + loader: "sass-loader", options: { sassOptions: { - indentedSyntax: false + indentedSyntax: false, }, }, }, @@ -53,7 +61,7 @@ module.exports = { { test: /\.(graphql|gql)$/, exclude: /node_modules/, - loader: 'graphql-tag/loader', + loader: "graphql-tag/loader", }, ], }, @@ -75,15 +83,15 @@ module.exports = { // npm package names are URL-safe, but some servers don't like @ symbols return `npm.${packageName.replace("@", "")}`; - } - } - } - } + }, + }, + }, + }, }, resolve: { - modules: [path.resolve('./node_modules')], + modules: [path.resolve("./node_modules")], alias: { - 'vue$': 'vue/dist/vue.esm.js' - } + vue$: "vue/dist/vue.esm.js", + }, }, -} +}; diff --git a/pyproject.toml b/pyproject.toml index eb1c126332cb282b1523921e001ee7c713851d46..828f987b1e31a24f9fac8d833860a598acc57cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ authors = [ "Hangzhi Yu <yuha@katharineum.de>", "Lloyd Meins <meinsll@katharineum.de>", "magicfelix <felix@felix-zauberer.de>", - "Benedict Suska <benedict.suska@teckids.de>" + "Benedict Suska <benedict.suska@teckids.de>", + "Lukas Weichelt <lukas.weichelt@teckids.de>" ] maintainers = [ "Jonathan Weth <dev@jonathanweth.de>", diff --git a/tox.ini b/tox.ini index 9b265e53f8a154838b5af4fd2a4d3475e9c69d40..ff7a8c4aa3f0d97c2224ac9e4998fdaa246265c7 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,7 @@ envlist = py37,py38,py39 [testenv] whitelist_externals = poetry sudo + node skip_install = true envdir = {toxworkdir}/globalenv commands_pre = @@ -14,6 +15,8 @@ commands_pre = poetry run aleksis-admin collectstatic --no-input commands = poetry run pytest --cov=. {posargs} aleksis/ +setenv= + NODE_PATH=cache/node_modules/ [testenv:selenium] setenv = @@ -27,6 +30,8 @@ commands = poetry run black --check --diff aleksis/ poetry run isort -c --diff --stdout aleksis/ poetry run flake8 {posargs} aleksis/ + node cache/node_modules/.bin/prettier --check . + node cache/node_modules/.bin/eslint aleksis/**/*/assets/**/*.{js,vue} [testenv:security] commands = @@ -46,6 +51,7 @@ commands = poetry run make -C docs/ html {posargs} commands = poetry run isort aleksis/ poetry run black aleksis/ + node cache/node_modules/.bin/prettier --write . [testenv:makemessages] commands =