diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..15b0fbe2891c2f685e867206fc6954620237b5d6 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + extends: [ + 'plugin:vue/strongly-recommended', + ], + rules: { + 'vue/no-unused-vars': 'off', + 'vue/multi-word-component-names': 'off' + } +} diff --git a/.gitignore b/.gitignore index 0faf3e4c3ecc7ff5de08a7c56ee313c488b183f6..dd85b53f78b12d78a2ff1053f4ab17e2601a62b2 100644 --- a/.gitignore +++ b/.gitignore @@ -62,9 +62,9 @@ docs/_build/ *.aux # Generated files -aleksis/node_modules/ -aleksis/static/ -aleksis/whoosh_index/ +/node_modules/ +/static/ +/whoosh_index/ poetry.lock .coverage @@ -74,8 +74,11 @@ htmlcov/ maintenance_mode_state.txt media/ package-lock.json +yarn.lock # VSCode .vscode/ .history/ *.code-workspace + +/cache diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..40db42c6689bd157e91cec65fda28693350b6332 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "stylelint-config-standard" +} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 542508a839105541cd8921af698aee9468453668..ccdb00145e58d584b9cf8f23051a6e29881a36ed 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Added +~~~~~ + +* Introduce GraphQL API + Fixed ~~~~~ diff --git a/Dockerfile b/Dockerfile index 4864ac54613a8823fbc118fe73291e127b7939e1..df38046b1aff30d14a480603c80d5d2987cf4cdd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM debian:bullseye-slim AS core +FROM debian:bookworm-slim AS core # Build arguments ARG EXTRAS="ldap,s3,sentry" -ARG APP_VERSION="" +ARG APP_VERSION="==2.10.1.dev0+20220801181456.7ba74939" # Configure Python to be nice inside Docker and pip to stfu ENV PYTHONUNBUFFERED 1 @@ -15,6 +15,7 @@ ENV PIP_USE_DEPRECATED legacy-resolver ENV DEBIAN_FRONTEND noninteractive # Configure app settings for build and runtime +ENV ALEKSIS_caching__dir /var/cache/aleksis ENV ALEKSIS_static__root /usr/share/aleksis/static ENV ALEKSIS_media__root /var/lib/aleksis/media ENV ALEKSIS_backup__location /var/lib/aleksis/backups @@ -58,7 +59,8 @@ RUN case ",$EXTRAS," in \ # Install core RUN set -e; \ - mkdir -p ${ALEKSIS_static__root} \ + mkdir -p ${ALEKSIS_caching__dir} \ + ${ALEKSIS_static__root} \ ${ALEKSIS_media__root} \ ${ALEKSIS_backup__location}; \ eatmydata pip install AlekSIS-Core\[$EXTRAS\]$APP_VERSION @@ -72,9 +74,12 @@ CMD ["/usr/local/bin/aleksis-docker-startup"] # Install assets FROM core as assets -RUN eatmydata aleksis-admin yarn install; \ +RUN eatmydata aleksis-admin webpack_bundle; \ eatmydata aleksis-admin collectstatic --no-input; \ rm -rf /usr/local/share/.cache +# FIXME Introduce deletion after we don't need materializecss anymore for SASS +# also in ONBUILD below +# rm -rf /usr/local/share/.cache ${ALEKSIS_caching__dir}/* # Clean up build dependencies FROM assets AS clean @@ -118,7 +123,7 @@ ONBUILD RUN set -e; \ if [ -n "$APPS" ]; then \ eatmydata pip install $APPS; \ fi; \ - eatmydata aleksis-admin yarn install; \ + eatmydata aleksis-admin webpack_bundle; \ eatmydata aleksis-admin collectstatic --no-input; \ rm -rf /usr/local/share/.cache; \ eatmydata apt-get remove --purge -y yarnpkg $BUILD_DEPS; \ diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js new file mode 100644 index 0000000000000000000000000000000000000000..95196ba8a18303fcdf0da9fb1a77ff0e488ca55d --- /dev/null +++ b/aleksis/core/assets/app.js @@ -0,0 +1,101 @@ +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' + +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', + }, + }, + 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), + }, + }, + }, + lang: { + locales: JSON.parse(document.getElementById("language-info-list").textContent), + current: JSON.parse(document.getElementById("current-language").textContent), + } +}) + +const apolloClient = new ApolloClient({ + 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"; + +Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it + +Vue.use(VueApollo) + +const apolloProvider = new VueApollo({ + defaultClient: apolloClient, +}) + +const router = new VueRouter({ + mode: "history", +// 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, + languageCode: JSON.parse(document.getElementById("current-language").textContent), + }), + components: { + "cache-notification": CacheNotification, + "language-form": LanguageForm, + "notification-list": NotificationList, + "sidenav-search": SidenavSearch, + }, + router +}) + +window.app = app; +window.router = router; diff --git a/aleksis/core/assets/components/CacheNotification.vue b/aleksis/core/assets/components/CacheNotification.vue new file mode 100644 index 0000000000000000000000000000000000000000..4491615e6c7b4f3b42187c9a4ba9c0b66bbff4fd --- /dev/null +++ b/aleksis/core/assets/components/CacheNotification.vue @@ -0,0 +1,25 @@ +<template> + <message-box :value="cache" type="warning"> + {{ this.$root.django.gettext('This page may contain outdated information since there is no internet connection.') }} + </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() + }, + } +</script> diff --git a/aleksis/core/assets/components/LanguageForm.vue b/aleksis/core/assets/components/LanguageForm.vue new file mode 100644 index 0000000000000000000000000000000000000000..a19ae796e6e54105734a51e213000fd383249434 --- /dev/null +++ b/aleksis/core/assets/components/LanguageForm.vue @@ -0,0 +1,61 @@ +<template> + <form method="post" ref="form" :action="action" id="language-form"> + <v-text-field + v-show="false" + name="csrfmiddlewaretoken" + :value="csrf_value" + type="hidden" + ></v-text-field> + <v-text-field + v-show="false" + name="next" + :value="next_url" + type="hidden" + ></v-text-field> + <input + name="language" + :value="current_language" + type="hidden" + > + <v-menu offset-y> + <template v-slot:activator="{ on, attrs }"> + <v-btn + depressed + v-bind="attrs" + v-on="on" + color="primary" + > + <v-icon icon color="white">mdi-translate</v-icon> + {{ current_language }} + </v-btn> + </template> + <v-list id="language-dropdown" class="dropdown-content"> + <v-list-item-group + v-model="current_language" + color="primary" + > + <v-list-item v-for="language in items" :key="language[0]" :value="language[0]" @click="submit(language[0])"> + <v-list-item-title>{{ language[1] }}</v-list-item-title> + </v-list-item> + </v-list-item-group> + </v-list> + </v-menu> + </form> +</template> + +<script> + export default { + data: () => ({ + items: JSON.parse(document.getElementById("language-info-list").textContent), + current_language: JSON.parse(document.getElementById("current-language").textContent), + }), + methods: { + submit: function (language) { + this.current_language = language; + // this.$refs.form.submit() + }, + }, + props: ["action", "csrf_value", "next_url"], + name: "language-form", + } +</script> diff --git a/aleksis/core/assets/components/MessageBox.vue b/aleksis/core/assets/components/MessageBox.vue new file mode 100644 index 0000000000000000000000000000000000000000..2a4cb17295835f5d3d1eb6f310a935eb4c2789be --- /dev/null +++ b/aleksis/core/assets/components/MessageBox.vue @@ -0,0 +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). + } +</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> +</template> + diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue new file mode 100644 index 0000000000000000000000000000000000000000..30fdbe9596487061d962c82af968f659fa524e5f --- /dev/null +++ b/aleksis/core/assets/components/SidenavSearch.vue @@ -0,0 +1,21 @@ +<script> + export default { + methods: { + submit: function () { + this.$refs.form.submit() + }, + }, + props: ["action", "placeholder"], + name: "sidenav-search", + } + // FIXME: implement suggestions etc, use "loading" attribute +</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> +</template> diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c05b112e4afadb58f51ef71ac0c76451508415f --- /dev/null +++ b/aleksis/core/assets/components/notifications/NotificationItem.vue @@ -0,0 +1,43 @@ +<template> + <ApolloMutation + :mutation="require('./markNotificationRead.graphql')" + :variables="{ id: this.notification.id }" + > + <template v-slot="{ mutate, loading, error }"> + <v-list-item + v-intersect="mutate" + > + <v-list-item-content> + <v-list-item-title>{{ notification.title }}</v-list-item-title> + + <v-list-item-subtitle> + <v-icon>mdi-clock-outline</v-icon> + {{ notification.created }} + </v-list-item-subtitle> + + <v-list-item-subtitle> + {{ notification.description }} + </v-list-item-subtitle> + </v-list-item-content> + + <v-list-item-action v-if="notification.link"> + <v-btn text :href="notification.link"> + {{ this.$root.django.gettext('More information →') }} + </v-btn> + </v-list-item-action> + + <v-list-item-icon> + <v-chip color="primary">{{ notification.sender }}</v-chip> + </v-list-item-icon> + </v-list-item> + </template> + </ApolloMutation> +</template> + +<script> + export default { + props: { + notification: Object, + }, + } +</script> diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue new file mode 100644 index 0000000000000000000000000000000000000000..8428fd7115cafdbfa92b74c9c2655ed043b23de7 --- /dev/null +++ b/aleksis/core/assets/components/notifications/NotificationList.vue @@ -0,0 +1,27 @@ +<template> + <ApolloQuery + :query="require('./myNotifications.graphql')" + :pollInterval="1000" + > + <template v-slot="{ result: { error, data }, isLoading }"> + <v-list two-line v-if="data && data.myNotifications.notifications"> + <NotificationItem + v-for="notification in data.myNotifications.notifications" + :key="notification.id" + :notification="notification" + /> + </v-list> + <p v-else>{{ this.$root.django.gettext('No notifications available yet.') }}</p> + </template> + </ApolloQuery> +</template> + +<script> + import NotificationItem from "./NotificationItem.vue"; + + export default { + components: { + NotificationItem, + }, + } +</script> diff --git a/aleksis/core/assets/components/notifications/markNotificationRead.graphql b/aleksis/core/assets/components/notifications/markNotificationRead.graphql new file mode 100644 index 0000000000000000000000000000000000000000..8cc7bed4325857b3407701900a356150d3614b68 --- /dev/null +++ b/aleksis/core/assets/components/notifications/markNotificationRead.graphql @@ -0,0 +1,8 @@ +mutation ($id: ID!) { + markNotificationRead(id: $id) { + notification { + id + read + } + } +} diff --git a/aleksis/core/assets/components/notifications/myNotifications.graphql b/aleksis/core/assets/components/notifications/myNotifications.graphql new file mode 100644 index 0000000000000000000000000000000000000000..efa51f2c82e523ef2d5515e88536a0c6b08005ef --- /dev/null +++ b/aleksis/core/assets/components/notifications/myNotifications.graphql @@ -0,0 +1,12 @@ +{ + myNotifications: whoAmI { + notifications { + id + title + description + link + created + sender + } + } +} diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js new file mode 100644 index 0000000000000000000000000000000000000000..94f4131baf7181ea2a299c518f5fe942b850926d --- /dev/null +++ b/aleksis/core/assets/index.js @@ -0,0 +1,4 @@ +import '@mdi/font/css/materialdesignicons.css' + +import "./util" +import "./app" diff --git a/aleksis/core/assets/util.js b/aleksis/core/assets/util.js new file mode 100644 index 0000000000000000000000000000000000000000..1b5041b216cdae06868440b14b88f3d69acee914 --- /dev/null +++ b/aleksis/core/assets/util.js @@ -0,0 +1,153 @@ +/* +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.") + }); + } +}); diff --git a/aleksis/core/management/__init__.py b/aleksis/core/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/webpack_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..809a22c1beea482525601d16b46b1313ee6d03bd --- /dev/null +++ b/aleksis/core/management/commands/webpack_bundle.py @@ -0,0 +1,34 @@ +import json +import os +import shutil + +from django.conf import settings + +from django_yarnpkg.management.base import BaseYarnCommand +from django_yarnpkg.yarn import yarn_adapter + +from ...util.frontend_helpers import get_apps_with_assets + + +class Command(BaseYarnCommand): + help = "Create webpack bundles for AlekSIS" # noqa + + def handle(self, *args, **options): + super(Command, self).handle(*args, **options) + + # Write webpack entrypoints for all apps + assets = { + app: {"dependOn": "core", "import": os.path.join(path, "index")} + for app, path in get_apps_with_assets().items() + } + assets["core"] = os.path.join(settings.BASE_DIR, "aleksis", "core", "assets", "index") + with open(os.path.join(settings.NODE_MODULES_ROOT, "webpack-entrypoints.json"), "w") as out: + json.dump(assets, out) + + # Install Node dependencies + yarn_adapter.install(settings.YARN_INSTALLED_APPS) + + # Run webpack + config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js") + shutil.copy(config_path, settings.NODE_MODULES_ROOT) + yarn_adapter.call_yarn(["run", "webpack"]) diff --git a/aleksis/core/menus.py b/aleksis/core/menus.py index 49ddd4af743769252cbdbbae26d7de1d68c55776..e2f68d031aeb59dad329cc72c03e740142a0ba48 100644 --- a/aleksis/core/menus.py +++ b/aleksis/core/menus.py @@ -7,12 +7,14 @@ MENUS = { "name": _("Login"), "url": settings.LOGIN_URL, "svg_icon": "mdi:login-variant", + "vuetify_icon": "mdi-login-variant", "validators": ["menu_generator.validators.is_anonymous"], }, { "name": _("Sign up"), "url": "account_signup", "svg_icon": "mdi:account-plus-outline", + "vuetify_icon": "mdi-account-plus-outline", "validators": [ "menu_generator.validators.is_anonymous", ("aleksis.core.util.predicates.permission_validator", "core.can_register"), @@ -22,6 +24,7 @@ MENUS = { "name": _("Accept invitation"), "url": "enter_invitation_code", "svg_icon": "mdi:key-outline", + "vuetify_icon": "mdi-key-outline", "validators": [ "menu_generator.validators.is_anonymous", ("aleksis.core.util.predicates.permission_validator", "core.invite_enabled"), @@ -31,6 +34,7 @@ MENUS = { "name": _("Dashboard"), "url": "index", "svg_icon": "mdi:home-outline", + "vuetify_icon": "mdi-home-outline", "validators": [ ("aleksis.core.util.predicates.permission_validator", "core.view_dashboard_rule") ], @@ -39,6 +43,7 @@ MENUS = { "name": _("Admin"), "url": "#", "svg_icon": "mdi:security", + "vuetify_icon": "mdi-security", "validators": [ ("aleksis.core.util.predicates.permission_validator", "core.view_admin_menu"), ], @@ -47,6 +52,7 @@ MENUS = { "name": _("Announcements"), "url": "announcements", "svg_icon": "mdi:message-alert-outline", + "vuetify_icon": "mdi-message-alert-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -58,6 +64,7 @@ MENUS = { "name": _("School terms"), "url": "school_terms", "svg_icon": "mdi:calendar-range-outline", + "vuetify_icon": "mdi-calendar-range-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -69,6 +76,7 @@ MENUS = { "name": _("Dashboard widgets"), "url": "dashboard_widgets", "svg_icon": "mdi:view-dashboard-outline", + "vuetify_icon": "mdi-view-dashboard-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -80,6 +88,7 @@ MENUS = { "name": _("Data management"), "url": "data_management", "svg_icon": "mdi:chart-donut", + "vuetify_icon": "mdi-chart-donut", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -91,6 +100,7 @@ MENUS = { "name": _("System status"), "url": "system_status", "svg_icon": "mdi:power-settings", + "vuetify_icon": "mdi-power-settings", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -102,6 +112,7 @@ MENUS = { "name": _("Configuration"), "url": "preferences_site", "svg_icon": "mdi:tune", + "vuetify_icon": "mdi-tune", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -113,12 +124,14 @@ MENUS = { "name": _("Data checks"), "url": "check_data", "svg_icon": "mdi:list-status", + "vuetify_icon": "mdi-list-status", "validators": ["menu_generator.validators.is_superuser"], }, { "name": _("Manage permissions"), "url": "manage_user_global_permissions", "svg_icon": "mdi:shield-outline", + "vuetify_icon": "mdi-shield-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -130,6 +143,7 @@ MENUS = { "name": _("Backend Admin"), "url": "admin:index", "svg_icon": "mdi:database-cog-outline", + "vuetify_icon": "mdi-database-cog-outline", "validators": [ "menu_generator.validators.is_superuser", ], @@ -138,6 +152,7 @@ MENUS = { "name": _("OAuth2 Applications"), "url": "oauth2_applications", "svg_icon": "mdi:gesture-tap-hold", + "vuetify_icon": "mdi-gesture-tap-hold", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -151,6 +166,7 @@ MENUS = { "name": _("People"), "url": "#", "svg_icon": "mdi:account-group-outline", + "vuetify_icon": "mdi-account-group-outline", "root": True, "validators": [ ("aleksis.core.util.predicates.permission_validator", "core.view_people_menu_rule") @@ -160,6 +176,7 @@ MENUS = { "name": _("Persons"), "url": "persons", "svg_icon": "mdi:account-outline", + "vuetify_icon": "mdi-account-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -171,6 +188,7 @@ MENUS = { "name": _("Groups"), "url": "groups", "svg_icon": "mdi:account-multiple-outline", + "vuetify_icon": "mdi-account-multiple-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -182,6 +200,7 @@ MENUS = { "name": _("Group types"), "url": "group_types", "svg_icon": "mdi:shape-outline", + "vuetify_icon": "mdi-shape-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -193,6 +212,7 @@ MENUS = { "name": _("Groups and child groups"), "url": "groups_child_groups", "svg_icon": "mdi:account-multiple-plus-outline", + "vuetify_icon": "mdi-account-multiple-plus-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -204,6 +224,7 @@ MENUS = { "name": _("Additional fields"), "url": "additional_fields", "svg_icon": "mdi:palette-swatch-outline", + "vuetify_icon": "mdi-palette-swatch-outline", "validators": [ ( "aleksis.core.util.predicates.permission_validator", @@ -215,6 +236,7 @@ MENUS = { "name": _("Invite person"), "url": "invite_person", "svg_icon": "mdi:account-plus-outline", + "vuetify_icon": "mdi-account-plus-outline", "validators": [ "menu_generator.validators.is_authenticated", ("aleksis.core.util.predicates.permission_validator", "core.can_invite"), @@ -240,6 +262,7 @@ MENUS = { "name": _("Stop impersonation"), "url": "impersonate-stop", "svg_icon": "mdi:stop", + "vuetify_icon": "mdi-stop", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.is_impersonate", @@ -249,6 +272,7 @@ MENUS = { "name": _("Account"), "url": "person", "svg_icon": "mdi:account-outline", + "vuetify_icon": "mdi-account-outline", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", @@ -258,6 +282,7 @@ MENUS = { "name": _("Preferences"), "url": "preferences_person", "svg_icon": "mdi:cog-outline", + "vuetify_icon": "mdi-cog-outline", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", @@ -267,6 +292,7 @@ MENUS = { "name": _("2FA"), "url": "two_factor:profile", "svg_icon": "mdi:two-factor-authentication", + "vuetify_icon": "mdi-two-factor-authentication", "validators": [ "menu_generator.validators.is_authenticated", ], @@ -275,6 +301,7 @@ MENUS = { "name": _("Change password"), "url": "account_change_password", "svg_icon": "mdi:form-textbox-password", + "vuetify_icon": "mdi-form-textbox-password", "validators": [ "menu_generator.validators.is_authenticated", ( @@ -287,6 +314,7 @@ MENUS = { "name": _("Third-party accounts"), "url": "socialaccount_connections", "svg_icon": "mdi:earth", + "vuetify_icon": "mdi-earth", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", @@ -296,6 +324,7 @@ MENUS = { "name": _("Authorized applications"), "url": "oauth2_provider:authorized-token-list", "svg_icon": "mdi:gesture-tap-hold", + "vuetify_icon": "mdi-gesture-tap-hold", "validators": [ "menu_generator.validators.is_authenticated", "aleksis.core.util.core_helpers.has_person", @@ -305,6 +334,7 @@ MENUS = { "name": _("Calendar Feeds"), "url": "ical_feed_list", "svg_icon": "mdi:calendar-multiple", + "vuetify_icon": "mdi-calendar-multiple", "validators": [ "menu_generator.validators.is_authenticated", ( @@ -318,6 +348,7 @@ MENUS = { "name": _("Logout"), "url": "logout", "svg_icon": "mdi:logout-variant", + "vuetify_icon": "mdi-logout-variant", "validators": ["menu_generator.validators.is_authenticated"], }, ], diff --git a/aleksis/core/preferences.py b/aleksis/core/preferences.py index 230e40686d0782151043723cef9edfffcf5a68b7..631a9337654db24782f823dd956c286855ca7792 100644 --- a/aleksis/core/preferences.py +++ b/aleksis/core/preferences.py @@ -198,6 +198,21 @@ class NotificationChannels(ChoicePreference): choices = get_notification_choices_lazy() +@person_preferences_registry.register +class Design(ChoicePreference): + """Change design (on supported pages).""" + + section = theme + name = "design" + default = "light" + verbose_name = _("Select Design") + choices = [ + # ("system", _("System Design")), + ("light", _("Light mode")), + # ("dark", _("Dark mode")), + ] + + @site_preferences_registry.register class PrimaryGroupPattern(StringPreference): """Regular expression to match primary group.""" diff --git a/aleksis/core/schema.py b/aleksis/core/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..7e637aa20612a5d486732d85201c05febcfeed3b --- /dev/null +++ b/aleksis/core/schema.py @@ -0,0 +1,110 @@ +import graphene +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 + + +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 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) + + 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 + + +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/settings.py b/aleksis/core/settings.py index 6ff73399849d267eeca7f37057d46ac5c6b0c843..3313076e8d2649bbbc0fa885e59ea9c5d84dcf62 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -43,7 +43,10 @@ _settings = LazySettings( ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Cache directory for external operations +CACHE_DIR = _settings.get("caching.dir", os.path.join(BASE_DIR, "cache")) SILENCED_SYSTEM_CHECKS = [] @@ -115,6 +118,7 @@ INSTALLED_APPS = [ "sass_processor", "django_any_js", "django_yarnpkg", + "webpack_loader", "django_tables2", "maintenance_mode", "menu_generator", @@ -155,6 +159,7 @@ INSTALLED_APPS = [ "django_filters", "oauth2_provider", "rest_framework", + "graphene_django", "dj_iconify.apps.DjIconifyConfig", ] @@ -164,7 +169,6 @@ INSTALLED_APPS += get_app_packages() STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", - "django_yarnpkg.finders.NodeModulesFinder", "sass_processor.finders.CssFinder", ] @@ -427,6 +431,11 @@ REST_FRAMEWORK = { ] } +# Configuration for GraphQL framework +GRAPHENE = { + "SCHEMA": "aleksis.core.schema.schema", +} + # LDAP config if _settings.get("ldap.uri", None): # LDAP dependencies are not necessarily installed, so import them here @@ -560,7 +569,7 @@ LOGOUT_REDIRECT_URL = "index" STATIC_ROOT = _settings.get("static.root", os.path.join(BASE_DIR, "static")) MEDIA_ROOT = _settings.get("media.root", os.path.join(BASE_DIR, "media")) -NODE_MODULES_ROOT = _settings.get("node_modules.root", os.path.join(BASE_DIR, "node_modules")) +NODE_MODULES_ROOT = CACHE_DIR YARN_INSTALLED_APPS = [ "cleave.js@^1.6.0", @@ -577,12 +586,45 @@ YARN_INSTALLED_APPS = [ "luxon@^2.3.2", "@iconify/iconify@^2.2.1", "@iconify/json@^2.1.30", + "@mdi/font@^6.9.96", + "apollo-boost@^0.4.9", + "deepmerge@^4.2.2", + "graphql@^15.8.0", + "graphql-tag@^2.12.6", + "sass@~1.32", + "vue@^2.7.7", + "vue-apollo@^3.1.0", + "vuetify@^2.6.7", + "vue-router@^3.5.2", + "css-loader@^6.7.1", + "sass-loader@^8.0", + "vue-loader@^15.0.0", + "vue-style-loader@^4.1.3", + "vue-template-compiler@^2.7.7", + "webpack@^5.73.0", + "webpack-bundle-tracker@^1.6.0", + "webpack-cli@^4.10.0", ] merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True) JS_URL = _settings.get("js_assets.url", STATIC_URL) -JS_ROOT = _settings.get("js_assets.root", NODE_MODULES_ROOT + "/node_modules") +JS_ROOT = _settings.get("js_assets.root", os.path.join(NODE_MODULES_ROOT, "node_modules")) + +WEBPACK_LOADER = { + "DEFAULT": { + "CACHE": not DEBUG, + "STATS_FILE": os.path.join(NODE_MODULES_ROOT, "webpack-stats.json"), + "BUNDLE_DIR_NAME": "", + "POLL_INTERVAL": 0.1, + "IGNORE": [r".+\.hot-update.js", r".+\.map"], + } +} +STATICFILES_DIRS = ( + os.path.join(NODE_MODULES_ROOT, "webpack_bundles"), + JS_ROOT, +) + SELECT2_CSS = JS_URL + "/select2/dist/css/select2.min.css" SELECT2_JS = JS_URL + "/select2/dist/js/select2.min.js" diff --git a/aleksis/core/static/public/vue_style.scss b/aleksis/core/static/public/vue_style.scss new file mode 100644 index 0000000000000000000000000000000000000000..9c91c57ddf7fb4e56e69daa40c142bc2ed4b589b --- /dev/null +++ b/aleksis/core/static/public/vue_style.scss @@ -0,0 +1,16 @@ +////////////// +// HEADINGS // +////////////// + +p, h1, h2, h3, h4, h5, h6, .card-title { + overflow-wrap: break-word; + hyphens: auto; +} + +///////////// +// HELPERS // +///////////// + +[v-cloak] { + display: none; +} diff --git a/aleksis/core/templates/core/index.html b/aleksis/core/templates/core/index.html index 766c88278ca688381356c35e79b3424b7fb5ce75..ed75f0512706299dda87b12f2a4ab9f8a613a595 100644 --- a/aleksis/core/templates/core/index.html +++ b/aleksis/core/templates/core/index.html @@ -19,21 +19,6 @@ </div> {% endif %} - {% for notification in unread_notifications %} - <figure class="alert primary scale-transition"> - <i class="material-icons left iconify" data-icon="mdi:information-outline"></i> - - <div class="right"> - <a class="btn-flat waves-effect" href="{% url "notification_mark_read" notification.id %}"> - <i class="material-icons center iconify" data-icon="mdi:close"></i> - </a> - </div> - - <figcaption>{{ notification.title }}</figcaption> - <p>{{ notification.description|linebreaks }}</p> - </figure> - {% endfor %} - {% include "core/partials/announcements.html" with announcements=announcements %} <div class="row" id="live_load"> diff --git a/aleksis/core/templates/core/notifications.html b/aleksis/core/templates/core/notifications.html index 60640670e6ca02ae788c4c37ff5f65d7c9969e2d..d7201867376e8618d08d9511dd94675a1bce1553 100644 --- a/aleksis/core/templates/core/notifications.html +++ b/aleksis/core/templates/core/notifications.html @@ -1,32 +1,9 @@ -{% extends 'core/base.html' %} +{% extends 'core/vue_base.html' %} {% load i18n static dashboard %} {% block browser_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %} {% block page_title %}{% blocktrans %}Notifications{% endblocktrans %}{% endblock %} - {% block content %} - {% if object_list %} - <ul class="collection"> - {% for notification in object_list %} - <li class="collection-item"> - <span class="badge new primary-color">{{ notification.sender }}</span> - <span class="title">{{ notification.title }}</span> - <p> - <i class="material-icons iconify left" data-icon="mdi:clock-outline"></i> {{ notification.created }} - </p> - <p> - {{ notification.description|linebreaks }} - </p> - {% if notification.link %} - <p> - <a href="{{ notification.link }}">{% blocktrans %}More information →{% endblocktrans %}</a> - </p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <p>{% blocktrans %}No notifications available yet.{% endblocktrans %}</p> - {% endif %} + <notification-list/> {% endblock %} diff --git a/aleksis/core/templates/core/partials/vue_avatar_content.html b/aleksis/core/templates/core/partials/vue_avatar_content.html new file mode 100644 index 0000000000000000000000000000000000000000..c94a0734fa28383a78bcb6f082347ddbb4f77826 --- /dev/null +++ b/aleksis/core/templates/core/partials/vue_avatar_content.html @@ -0,0 +1,25 @@ +{% load rules i18n %} +{% has_perm 'core.view_avatar_rule' request.user person_or_user as can_view_avatar %} +{% has_perm 'core.view_photo_rule' request.user person_or_user as can_view_photo %} +{% if SITE_PREFERENCES.account__person_prefer_photo and person_or_user.photo and can_view_photo %} + <img src="{{ person_or_user.photo.url }}" + alt="{{ person_or_user.full_name }}" {% if title %} title="{{ person_or_user.full_name }}"{% endif %}/> +{% elif person_or_user.identicon_url %} + + {# If this is a person #} + {% if can_view_avatar %} + <img src="{{ person_or_user.avatar_url }}" + alt="{{ person_or_user.full_name }} ({% trans "Avatar" %})" {% if title %} + title="{{ person_or_user.full_name }} ({% trans "Avatar" %})"{% endif %}/> + {% else %} + <img src="{{ person_or_user.identicon_url }}" + alt="{{ person_or_user.full_name }} ({% trans "Identicon" %})" {% if title %} + title="{{ person_or_user.full_name }} ({% trans "Identicon" %})"{% endif %} /> + {% endif %} + +{% else %} + {# There is a user without a person #} + <v-icon> + <i class="material-icons">person</i> + </v-icon> +{% endif %} diff --git a/aleksis/core/templates/core/partials/vue_footer_menu.html b/aleksis/core/templates/core/partials/vue_footer_menu.html new file mode 100644 index 0000000000000000000000000000000000000000..c751f8cdc9aa64b7763e11c63d40a3232ffd4706 --- /dev/null +++ b/aleksis/core/templates/core/partials/vue_footer_menu.html @@ -0,0 +1,10 @@ +{# -*- engine:django -*- #} + +{% for item in FOOTER_MENU.items.all %} + <v-btn plain tile href="{{ item.url }}" color="primary"> + {% if item.icon %} + <v-icon left>mdi-{{ item.icon }}</v-icon> + {% endif %} + {{ item.name }} + </v-btn> +{% endfor %} diff --git a/aleksis/core/templates/core/partials/vue_language_form.html b/aleksis/core/templates/core/partials/vue_language_form.html new file mode 100644 index 0000000000000000000000000000000000000000..a1a7953599b1fc652a25c7eaa6870106c8d2852f --- /dev/null +++ b/aleksis/core/templates/core/partials/vue_language_form.html @@ -0,0 +1,36 @@ +{# -*- engine:django -*- #} + +{% load i18n %} + + +<form action="{% url 'set_language' %}" method="post" class="language-form"> + {% csrf_token %} + <input name="next" type="hidden" value="{{ request.get_full_path }}"> + + {% get_current_language as LANGUAGE_CODE %} + {% get_language_info_list for request.site.preferences.internationalisation__languages as languages %} + + {# Select #} + <!--<div class="input-field language-field"> + <span>{% trans "Language" %}</span> + <select name="language"> + {% for language in languages %} + <option value="{{ language.code }}" {% if language.code == LANGUAGE_CODE %} + selected {% endif %}>{{ language.name_local }}</option> + {% endfor %} + </select> + </div>--> + <v-select + :items="items" + label="{% trans "Language" %}" + color="white" + class="white--text" + ></v-select> + + {# Submit button (only visible if JS isn't activated #} + <p class="language-submit-p"> + <button type="submit" class="btn-flat waves-effect waves-light white-text"> + {% trans "Select language" %} + </button> + </p> +</form> diff --git a/aleksis/core/templates/core/partials/vue_no_person.html b/aleksis/core/templates/core/partials/vue_no_person.html new file mode 100644 index 0000000000000000000000000000000000000000..f49093686e8fae83f6af3a0233e42650bd8bbd26 --- /dev/null +++ b/aleksis/core/templates/core/partials/vue_no_person.html @@ -0,0 +1,21 @@ +{# -*- engine:django -*- #} + +{% load i18n %} + +{% if user.person.is_dummy or not user.person and not user.is_anonymous %} + <message-box type="error"> + + {% if user.person.is_dummy %} + {% blocktrans %} + Your administrator account is not linked to any person. Therefore, + a dummy person has been linked to your account. + {% endblocktrans %} + {% else %} + {% blocktrans %} + Your user account is not linked to a person. This means you + cannot access any school-related information. Please contact + the managers of AlekSIS at your school. + {% endblocktrans %} + {% endif %} + </message-box> +{% endif %} diff --git a/aleksis/core/templates/core/partials/vue_sidenav.html b/aleksis/core/templates/core/partials/vue_sidenav.html new file mode 100644 index 0000000000000000000000000000000000000000..a9701b689c156f7618edd68c7c54f89ea2106a16 --- /dev/null +++ b/aleksis/core/templates/core/partials/vue_sidenav.html @@ -0,0 +1,64 @@ +{# -*- engine:django -*- #} + +{% load menu_generator data_helpers %} + +{% get_menu "NAV_MENU_CORE" as core_menu %} + +{% for item in core_menu %} + {% if not item.submenu %} + <v-list-item {% if item.selected %} input-value="true" {% endif %} + {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url }}"> + <v-list-item-icon> + {% if item.vuetify_icon %} + <v-icon>{{ item.vuetify_icon }}</v-icon> + {% elif item.icon_class %} + <i class="{{ item.icon_class }}"></i> + {% elif item.icon %} + <i class="material-icons">{{ item.icon }}</i> + {% elif item.svg_icon %} + <i class="material-icons iconify" data-icon="{{ item.svg_icon }}"></i> + {% endif %} + </v-list-item-icon> + <v-list-item-title>{{ item.name }}</v-list-item-title> + {% build_badge item as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} + </v-list-item> + {% endif %} + {% if item.submenu %} + <v-list-group {% if item.selected %} value="true" {% endif %} + {% if item.new_tab %} target="_blank" {% endif %} href="{{ item.url|default:"#" }}" + {% if item.vuetify_icon %}prepend-icon="{{ item.vuetify_icon }}"{% endif %} + > + <template v-slot:activator> + <v-list-item-title>{{ item.name }}</v-list-item-title> + </template> + {% build_badge item as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} + {% for menu in item.submenu %} + <v-list-item {% if menu.selected %} input-value="true" {% endif %} href="{{ menu.url }}"> + <v-list-item-icon> + {% if menu.vuetify_icon %} + <v-icon>{{ menu.vuetify_icon }}</v-icon> + {% elif menu.icon_class %} + <i class="{{ menu.icon_class }}"></i> + {% elif menu.icon %} + <i class="material-icons">{{ menu.icon }}</i> + {% elif menu.svg_icon %} + <i class="material-icons iconify" data-icon="{{ menu.svg_icon }}"></i> + {% endif %} + </v-list-item-icon> + <v-list-item-title>{{ menu.name }}</v-list-item-title> + {% build_badge menu as badge %} + {% if badge %} + <span class="new badge sidenav-badge"> {{ badge }}</span> + {% endif %} + </a> + </v-list-item> + {% endfor %} + </v-list-group> + {% endif %} +{% endfor %} diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html new file mode 100644 index 0000000000000000000000000000000000000000..22ea3d3a3ab3267c25ded2161d7702e1ca45f220 --- /dev/null +++ b/aleksis/core/templates/core/vue_base.html @@ -0,0 +1,222 @@ +{# -*- engine:django -*- #} + +{% load i18n menu_generator static sass_tags any_js rules html_helpers %} +{% load render_bundle from webpack_loader %} +{% get_current_language as LANGUAGE_CODE %} +{% get_available_languages as LANGUAGES %} + + +<!DOCTYPE html> +<html lang="{{ LANGUAGE_CODE }}"> +<head> + {% include "core/partials/meta.html" %} + + <title> + {% block no_browser_title %} + {% block browser_title %}{% endblock %} — + {% endblock %} + {{ request.site.preferences.general__title }} + </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> + + {# Add i18n names for calendar (for use in datepicker) #} + {# Passing the locale is not necessary for the scripts to work, but prevents caching issues #} + <script src="{% url "javascript-catalog" %}?locale={{ LANGUAGE_CODE }}" type="text/javascript"></script> + <script src="{% url "calendarweek_i18n_js" %}?first_day=6&locale={{ LANGUAGE_CODE }}" + type="text/javascript"></script> + + {% if SENTRY_ENABLED %} + {% if SENTRY_TRACE_ID %} + <meta name="sentry-trace" content="{{ SENTRY_TRACE_ID }}"/> + {% endif %} + {% include_js "Sentry" %} + {{ SENTRY_SETTINGS|json_script:"sentry_settings" }} + <script type="text/javascript"> + const sentry_settings = JSON.parse(document.getElementById('sentry_settings').textContent); + + Sentry.init({ + dsn: sentry_settings.dsn, + environment: sentry_settings.environment, + tracesSampleRate: sentry_settings.traces_sample_rate, + integrations: [new Sentry.Integrations.BrowserTracing()] + }); + </script> + {% endif %} + + <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 %}> +<main id="app"> + <v-app v-cloak> + {% if not no_menu %} + <v-navigation-drawer app v-model="drawer"> + <v-list nav dense shaped> + <v-list-item class="logo"> + {% static "img/aleksis-banner.svg" as aleksis_banner %} + <a id="logo-container" href="/" class="brand-logo"> + <img src="{% firstof request.site.preferences.theme__logo.url aleksis_banner %}" + alt="{{ request.site.preferences.general__title }} – Logo" style="width: 100%"> + </a> + </v-list-item> + {% 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> + </v-list-item> + {% endif %} + {% include "core/partials/vue_sidenav.html" %} + </v-list> + + </v-navigation-drawer> + {% endif %} + <v-app-bar app color="primary white--text"> + <v-app-bar-nav-icon @click="drawer = !drawer" color="white"></v-app-bar-nav-icon> + + <v-toolbar-title tag="a" class="white--text text-decoration-none" href="{% url "index" %}"> + {{ request.site.preferences.general__title }} + </v-toolbar-title> + + <v-spacer></v-spacer> + <language-form action="{% url "set_language" %}" csrf_value={{ csrf_token }} next_url={{ request.get_full_path }}></language-form> + {% if user.is_authenticated %} + <v-menu offset-y> + <template v-slot:activator="{ on, attrs }"> + <v-btn depressed color="primary" v-bind="attrs" v-on="on"> + <v-icon color="white"> + {% if request.user.person.unread_notifications_count > 0 %} + mdi-bell-badge-outline + {% else %} + mdi-bell-outline + {% endif %} + </v-icon> + </v-btn> + </template> + <notification-list/> + </v-menu> + <v-menu offset-y> + <template v-slot:activator="{ on, attrs }"> + <v-avatar v-bind="attrs" v-on="on"> + {% include "core/partials/vue_avatar_content.html" with person_or_user=request.user.person %} + </v-avatar> + </template> + {% get_menu "NAVBAR_ACCOUNT_MENU" as account_menu %} + <v-list> + <v-subheader>REPORTS</v-subheader> + {% for item in account_menu %} + {% if item.divider %} + <v-divider></v-divider> + {% endif %} + <v-list-item href="{{ item.url }}"> + <v-list-item-icon> + {% if item.vuetify_icon %} + <v-icon>{{ item.vuetify_icon }}</v-icon> + {% elif item.icon_class %} + <i class="{{ item.icon_class }}"></i> + {% elif item.icon %} + <i class="material-icons">{{ item.icon }}</i> + {% elif item.svg_icon %} + <i class="material-icons iconify" data-icon="{{ item.svg_icon }}"></i> + {% endif %} + </v-list-item-icon> + <v-list-item-title>{{ item.name }}</v-list-item-title> + </a> + </v-list-item> + {% endfor %} + </v-list> + </v-menu> + {% else %} + <v-btn icon href="{% url "notifications" %}"> + <v-icon>mdi-bell-outline</v-icon> + </v-btn> + {% endif %} + </v-app-bar> + <v-main> + <v-container> + <cache-notification></cache-notification> + {% include 'core/partials/vue_no_person.html' %} + + {% if messages %} + {% for message in messages %} + <message-box type="{{ message.tags }}">{{ message }}</message-box> + {% endfor %} + {% endif %} + {% block no_page_title %} + <h1 class="text-h2">{% block page_title %}{% endblock %}</h1> + {% endblock %} + {% block content %}{% endblock %} + </v-container> + </v-main> + + <v-footer app absolute inset class="white--text"> + <v-container> + <v-row> + <v-col cols="12" lg="6"> + {% include "core/partials/vue_footer_menu.html" %} + </v-col> + <v-col cols="12" > + <v-row> + <v-btn plain rounded href="{% url "about_aleksis" %}" color="white"> + {% trans "About AlekSIS® — The Free School Information System" %} + </v-btn> + <span>© The AlekSIS Team</span> + <v-spacer></v-spacer> + {% if request.site.preferences.footer__imprint_url %} + <v-btn plain rounded href="{{ request.site.preferences.footer__imprint_url }}" color="white"> + {% trans "Imprint" %} + </v-btn> + {% endif %} + {% if request.site.preferences.footer__privacy_url and request.site.preferences.footer__imprint_url %} + · + {% endif %} + {% if request.site.preferences.footer__privacy_url %} + <v-btn plain rounded href="{{ request.site.preferences.footer__privacy_url }}" color="white"> + {% trans "Privacy Policy" %} + </v-btn> + {% endif %} + </v-row> + </v-col> + </v-row> + </v-container> + </v-footer> + </v-app> +</main> + + +{% include_js "luxon" %} +{#{% include_js "materialize" %}#} +{% include_js "sortablejs" %} +{# Fixme: das muss weg ↓ #} +{% include_js "jquery-sortablejs" %} +{% absolute_url "graphql" as GRAPHQL_URL %} +{{ GRAPHQL_URL|json_script:"graphql-url" }} +{{ request.user.person.preferences.theme__design|json_script:"design-mode" }} +{{ request.site.preferences.theme__primary|json_script:"primary-color" }} +{{ request.site.preferences.theme__secondary|json_script:"secondary-color" }} +{{ LANGUAGE_CODE|json_script:"current-language" }} +{{ LANGUAGES|json_script:"language-info-list" }} +<script type="text/javascript" src="{% static 'js/search.js' %}"></script> +{% render_bundle 'core' %} +{% block extra_body %}{% endblock %} +</body> +</html> diff --git a/aleksis/core/templates/core/vue_dummy.html b/aleksis/core/templates/core/vue_dummy.html new file mode 100644 index 0000000000000000000000000000000000000000..c5ae6f471542d90828a50e00463e80d9167985ba --- /dev/null +++ b/aleksis/core/templates/core/vue_dummy.html @@ -0,0 +1,9 @@ +{% extends 'core/vue_base.html' %} +{% load i18n static dashboard %} + +{% block browser_title %}{% blocktrans %}Dummy page{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Dummy page{% endblocktrans %}{% endblock %} + +{% block content %} + <p>Nothing here</p> +{% endblock %} diff --git a/aleksis/core/templatetags/html_helpers.py b/aleksis/core/templatetags/html_helpers.py index 34066192527930c2a4096f49deb3d247f883c31e..8ca5be151d2edc39bf6787e9e51eabb3350714a0 100644 --- a/aleksis/core/templatetags/html_helpers.py +++ b/aleksis/core/templatetags/html_helpers.py @@ -2,6 +2,7 @@ import random import string from django import template +from django.shortcuts import reverse from bs4 import BeautifulSoup @@ -40,3 +41,9 @@ def generate_random_id(prefix: str, length: int = 10) -> str: return prefix + "".join( random.choice(string.ascii_lowercase) for i in range(length) # noqa: S311 ) + + +@register.simple_tag(takes_context=True) +def absolute_url(context, view_name, *args, **kwargs): + request = context["request"] + return request.build_absolute_uri(reverse(view_name, args=args, kwargs=kwargs)) diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index e76da89d18ed2078fc2405db11829aa344725fdf..c441d2ab8430d26faf539dbcbf4d69b5824196d5 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -3,12 +3,14 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path +from django.views.decorators.csrf import csrf_exempt from django.views.i18n import JavaScriptCatalog import calendarweek.django import debug_toolbar from ckeditor_uploader import views as ckeditor_uploader_views from django_js_reverse.views import urls_js +from graphene_django.views import GraphQLView from health_check.urls import urlpatterns as health_urls from oauth2_provider.views import ConnectDiscoveryInfoView from rules.contrib.views import permission_required @@ -21,6 +23,7 @@ urlpatterns = [ path(settings.MEDIA_URL.removeprefix("/"), include("titofisto.urls")), path("manifest.json", views.ManifestView.as_view(), name="manifest"), path("serviceworker.js", views.ServiceWorkerView.as_view(), name="service_worker"), + path("vue_dummy/", views.vue_dummy, name="vue_dummy"), path("offline/", views.OfflineView.as_view(), name="offline"), path("about/", views.about, name="about_aleksis"), path("accounts/signup/", views.AccountRegisterView.as_view(), name="account_signup"), @@ -95,11 +98,6 @@ urlpatterns = [ path("", views.index, name="index"), path("notifications/", views.NotificationsListView.as_view(), name="notifications"), path("dashboard/edit/", views.EditDashboardView.as_view(), name="edit_dashboard"), - path( - "notifications/mark-read/<int:id_>", - views.notification_mark_read, - name="notification_mark_read", - ), path("groups/group_type/create", views.edit_group_type, name="create_group_type"), path( "groups/group_type/<int:id_>/delete", @@ -146,6 +144,7 @@ urlpatterns = [ name="oauth2_provider:authorize", ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True)), name="graphql"), path("__i18n__/", include("django.conf.urls.i18n")), path( "ckeditor/upload/", diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index 67adbf943886d5e503a9009e56b8eeb7f1ed823c..45a2253613b74d97a59c7b7711ae8c5ab31af444 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -72,18 +72,18 @@ def get_app_packages(only_official: bool = False) -> Sequence[str]: return apps -def get_app_settings_module(app: str) -> Optional[ModuleType]: - """Get the settings module of an app.""" +def get_app_module(app: str, name: str) -> Optional[ModuleType]: + """Get a named module of an app.""" pkg = ".".join(app.split(".")[:-2]) - mod_settings = None + while "." in pkg: try: - return import_module(pkg + ".settings") + return import_module(f"{pkg}.{name}") except ImportError: # Import errors are non-fatal. pkg = ".".join(pkg.split(".")[:-1]) - # The app does not have settings + # The app does not have this module return None @@ -100,7 +100,7 @@ def merge_app_settings( potentially malicious apps! """ for app in get_app_packages(): - mod_settings = get_app_settings_module(app) + mod_settings = get_app_module(app, "settings") if not mod_settings: continue @@ -131,7 +131,7 @@ def get_app_settings_overrides() -> dict[str, Any]: overrides = {} for app in get_app_packages(True): - mod_settings = get_app_settings_module(app) + mod_settings = get_app_module(app, "settings") if not mod_settings: continue diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..034b1a0be59bb32c5caaa2917e09d71f213d392f --- /dev/null +++ b/aleksis/core/util/frontend_helpers.py @@ -0,0 +1,15 @@ +import os + +from .core_helpers import get_app_module, get_app_packages + + +def get_apps_with_assets(): + """Get a dictionary of apps that ship frontend assets.""" + assets = {} + for app in get_app_packages(): + mod = get_app_module(app, "apps") + path = os.path.join(os.path.dirname(mod.__file__), "assets") + if os.path.isdir(path): + package = ".".join(app.split(".")[:-2]) + assets[package] = path + return assets diff --git a/aleksis/core/views.py b/aleksis/core/views.py index ea910292f63c7e1fdba3f7144b4128641d2b1446..01350da6482743593964dfa52f3e44ae695e1e20 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -99,7 +99,6 @@ from .models import ( DummyPerson, Group, GroupType, - Notification, OAuthApplication, PDFFile, Person, @@ -550,18 +549,9 @@ class TestPDFGenerationView(PermissionRequiredMixin, RenderPDFView): permission_required = "core.test_pdf_rule" -@permission_required( - "core.mark_notification_as_read_rule", fn=objectgetter_optional(Notification, None, False) -) -def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse: - """Mark a notification read.""" - notification = objectgetter_optional(Notification, None, False)(request, id_) - - notification.read = True - notification.save() - - # Redirect to dashboard as this is only used from there if JavaScript is unavailable - return redirect("index") +def vue_dummy(request: HttpRequest) -> HttpResponse: + # FIXME remove together with URL route and template + return render(request, "core/vue_dummy.html", {}) @permission_required("core.view_announcements_rule") diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js new file mode 100644 index 0000000000000000000000000000000000000000..345972389a066fd8f69ae8b821af3ff6019322f0 --- /dev/null +++ b/aleksis/core/webpack.config.js @@ -0,0 +1,90 @@ +const fs = require('fs'); +const path = require('path'); +const webpack = require('webpack'); +const BundleTracker = require('webpack-bundle-tracker'); +const { VueLoaderPlugin } = require('vue-loader'); + +module.exports = { + context: __dirname, + entry: JSON.parse(fs.readFileSync('./webpack-entrypoints.json')), + output: { + path: path.resolve('./webpack_bundles/'), + filename: "[name]-[hash].js", + chunkFilename: "[id]-[chunkhash].js", + }, + plugins: [ + new BundleTracker({filename: './webpack-stats.json'}), + new VueLoaderPlugin(), + ], + module: { + rules: [ + { + test: /\.vue$/, + use: { + loader: 'vue-loader', + options: { + transpileOptions: { + transforms: { + dangerousTaggedTemplateString: true + } + } + } + }, + }, + { + test: /\.(css)$/, + use: ['vue-style-loader', 'css-loader'], + }, + { + test: /\.s(c|a)ss$/, + use: [ + 'vue-style-loader', + 'css-loader', + { + loader: 'sass-loader', + options: { + implementation: require('sass'), + sassOptions: { + indentedSyntax: true + }, + }, + }, + ], + }, + { + test: /\.(graphql|gql)$/, + exclude: /node_modules/, + loader: 'graphql-tag/loader', + }, + ], + }, + optimization: { + runtimeChunk: "single", + splitChunks: { + chunks: "all", + maxInitialRequests: Infinity, + minSize: 0, + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name(module) { + // get the name. E.g. node_modules/packageName/not/this/part.js + // or node_modules/packageName + const packageName = module.context.match( + /[\\/]node_modules[\\/](.*?)([\\/]|$)/ + )[1]; + + // npm package names are URL-safe, but some servers don't like @ symbols + return `npm.${packageName.replace("@", "")}`; + } + } + } + } + }, + resolve: { + modules: [path.resolve('./node_modules')], + alias: { + 'vue$': 'vue/dist/vue.esm.js' + } + }, +} diff --git a/docs/admin/10_install.rst b/docs/admin/10_install.rst index 05baa14a8c76f91b3407c47a97c2609c0decad6d..3f350d5f9199ac265b6be906ad284a20705a5bf9 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -16,7 +16,7 @@ AlekSIS will need and use the following paths: * `/var/lib/aleksis/media` for file storage (Django media) * `/var/backups/aleksis` for backups of database and media files * `/usr/local/share/aleksis/static` for static files - * `/usr/local/share/aleksis/node_modules` for frontend dependencies + * `//var/cache/aleksis` for building frontend assets etc. You can change any of the paths as you like. @@ -76,7 +76,8 @@ Create the directories for storage .. code-block:: shell mkdir -p /etc/aleksis \ - /usr/share/aleksis/{static,node_modules} \ + /usr/share/aleksis/static \ + /var/cache/aleksis \ /var/lib/aleksis/media \ /var/backups/aleksis @@ -91,7 +92,7 @@ favourite text editor and adding the following configuration. static = { root = "/usr/local/share/aleksis/static", url = "/static/" } media = { root = "/var/lib/aleksis/media", url = "/media/" } - node_modules = { root = "/usr/local/share/aleksis/node_modules" } + caching = { dir = "/var/cache/aleksis" } secret_key = "SomeRandomValue" [http] @@ -142,7 +143,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`: .. code-block:: shell pip3 install aleksis - aleksis-admin yarn install + aleksis-admin webpack_bundle aleksis-admin collectstatic aleksis-admin migrate aleksis-admin createinitialrevisions diff --git a/docs/conf.py b/docs/conf.py index 55cb1422da38894574f40742cfbd068a29e27585..5d7a78f8e5caf4f590838c22d19ae6615be9da12 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,9 +29,9 @@ copyright = "2019-2022 The AlekSIS team" author = "The AlekSIS Team" # The short X.Y version -version = "2.10" +version = "3.0" # The full version, including alpha/beta/rc tags -release = "2.10.2.dev0" +release = "3.0.dev0" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 25fd2f055a4136e7945f970258ebf282747b9379..1ed39af7a2dc3df00da7ff9fa8f5acdf3a855126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "AlekSIS-Core" -version = "2.10.2.dev0" +version = "3.0.dev0" packages = [ { include = "aleksis" } ] @@ -129,6 +129,8 @@ pycountry = "^22.0.0" django-ical = "^1.8.3" django-iconify = "^0.3" customidenticon = "^0.1.5" +graphene-django = "^2.15.0" +django-webpack-loader = "^1.6.0" [tool.poetry.extras] ldap = ["django-auth-ldap"] diff --git a/tox.ini b/tox.ini index ccee8b744148810a678afbc430480fbf19f60051..9b265e53f8a154838b5af4fd2a4d3475e9c69d40 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ skip_install = true envdir = {toxworkdir}/globalenv commands_pre = poetry install -E ldap - poetry run aleksis-admin yarn install + poetry run aleksis-admin webpack_bundle poetry run aleksis-admin collectstatic --no-input commands = poetry run pytest --cov=. {posargs} aleksis/