diff --git a/.eslintrc.js b/.eslintrc.js index 4c2043012828bd16438eb4f36472ad48460eb6e4..3bdc1f231b3a75f10ae3ba0687e8c649d7218082 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,7 +9,6 @@ module.exports = { "vue/multi-word-component-names": "off", }, env: { - browser: true, - node: true, + es2021: true, }, }; diff --git a/.gitignore b/.gitignore index dd85b53f78b12d78a2ff1053f4ab17e2601a62b2..9f60735a5e85703360c8669a34fa01622a079afd 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,5 @@ yarn.lock *.code-workspace /cache +/node_modules +.vite diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 22864314148d1bada0cccb4ef5f828235a9b8cd5..6efe90b75c7e1ae7ba4a387bcc08417172eab585 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,12 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Deprecated +~~~~~~~~~~ + +* The `webpack_bundle` management command is replaced by the new `vite` + command. The `webpack_bundle` command will be removed in AlekSIS-Core 4.0. + Added ~~~~~ @@ -19,6 +25,8 @@ Changed ~~~~~~~ * Rewrite of frontend using Vuetify + * The runuwsgi dev server now starts a Vite dev server with HMR in the + background * OIDC scope "profile" now exposes the avatar instead of the official photo * Based on Django 4.0 * Use built-in Redis cache backend @@ -27,6 +35,7 @@ Changed requests * Incorporate SPDX license list for app licenses on About page * [Dev] The undocumented field `check` on `DataCheckResult` was renamed to `data_check` +* Frontend bundling migrated from Webpack to Vite Fixed ~~~~~ diff --git a/Dockerfile b/Dockerfile index 914ee330f0d7e6646ba62c4d630747501b72d4f4..44159bb3fd185d4eb11224e48139319c9468b019 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,7 +75,7 @@ CMD ["/usr/local/bin/aleksis-docker-startup"] # Install assets FROM core as assets -RUN eatmydata aleksis-admin webpack_bundle; \ +RUN eatmydata aleksis-admin vite build; \ eatmydata aleksis-admin collectstatic --no-input; \ rm -rf /usr/local/share/.cache # FIXME Introduce deletion after we don't need materializecss anymore for SASS @@ -124,7 +124,7 @@ ONBUILD RUN set -e; \ if [ -n "$APPS" ]; then \ eatmydata pip install $APPS; \ fi; \ - eatmydata aleksis-admin webpack_bundle; \ + eatmydata aleksis-admin vite build; \ 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 index e34a49bc0b6c914743524323f5c9a5e5532e26f9..4c3830acb049121f5afe3105c60902799e9b5e23 100644 --- a/aleksis/core/assets/app.js +++ b/aleksis/core/assets/app.js @@ -34,16 +34,24 @@ const apolloClient = new ApolloClient({ uri: window.location.origin + "/django/graphql/", }); -const apolloProvider = new VueApollo({ - defaultClient: apolloClient, -}); - import App from "./App.vue"; - +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"; +import gqlSystemProperties from "./systemProperties.graphql"; 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", }); @@ -61,6 +69,22 @@ const app = new Vue({ // FIXME: maybe just use window.django in every component or find a suitable way to access this property everywhere showCacheAlert: false, }), + apollo: { + systemProperties: gqlSystemProperties, + }, + 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, + CeleryProgressBottom, + }, router, i18n, }); diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue index 58bf7bf6d718302279cd9d2d5d9451b6bdf9a644..ea044a9b6a0bca4ed7a84fdcc4ae4ef156826e87 100644 --- a/aleksis/core/assets/components/SidenavSearch.vue +++ b/aleksis/core/assets/components/SidenavSearch.vue @@ -1,4 +1,6 @@ <script> +import gqlSearchSnippets from "./searchSnippets.graphql"; + export default { methods: { submit: function () { @@ -22,7 +24,7 @@ export default { <template> <ApolloQuery - :query="require('./searchSnippets.graphql')" + :query="gqlSearchSnippets" :variables="{ q, }" diff --git a/aleksis/core/assets/components/about/InstalledAppsList.vue b/aleksis/core/assets/components/about/InstalledAppsList.vue index 491e3df15ece49cf736f037212a0bc668a924bed..704eedc97e172f7f840f4023d0a472a0d2fc7907 100644 --- a/aleksis/core/assets/components/about/InstalledAppsList.vue +++ b/aleksis/core/assets/components/about/InstalledAppsList.vue @@ -1,5 +1,5 @@ <template> - <ApolloQuery :query="require('./installedApps.graphql')"> + <ApolloQuery :query="gqlInstalledApps"> <template #default="{ result: { error, data }, isLoading }"> <v-row v-if="isLoading"> <v-col @@ -31,6 +31,7 @@ <script> import InstalledAppCard from "./InstalledAppCard.vue"; +import gqlInstalledApps from "./installedApps.graphql"; export default { name: "InstalledAppsList", diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue index 06b28fc37f52f5440442815207d901fe42cdcd69..86cc128471428a50f474a28d38ea19925e9b6d04 100644 --- a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue +++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue @@ -86,13 +86,15 @@ <script> import BackButton from "../BackButton.vue"; import MessageBox from "../MessageBox.vue"; +import gqlCeleryProgress from "./celeryProgress.graphql"; +import gqlCeleryProgressFetched from "./celeryProgressFetched.graphql"; export default { name: "CeleryProgress", components: { BackButton, MessageBox }, apollo: { celeryProgressByTaskId: { - query: require("./celeryProgress.graphql"), + query: gqlCeleryProgress, variables() { return { taskId: this.$route.params.taskId, @@ -114,7 +116,7 @@ export default { if (newState === "SUCCESS" || newState === "ERROR") { this.$apollo.queries.celeryProgressByTaskId.stopPolling(); this.$apollo.mutate({ - mutation: require("./celeryProgressFetched.graphql"), + mutation: gqlCeleryProgressFetched, variables: { taskId: this.$route.params.taskId, }, diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue index 2bb95431cecd66bc68789bc1bfe7c5977b68cfb6..410e57fda0eca36d3acaca7fc0318174fd71d318 100644 --- a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue +++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue @@ -25,6 +25,7 @@ <script> import TaskListItem from "./TaskListItem.vue"; +import gqlCeleryProgressButton from "./celeryProgressBottom.graphql"; export default { name: "CeleryProgressBottom", @@ -45,7 +46,7 @@ export default { }, apollo: { celeryProgressByUser: { - query: require("./celeryProgressBottom.graphql"), + query: gqlCeleryProgressButton, pollInterval: 1000, }, }, diff --git a/aleksis/core/assets/components/notifications/NotificationItem.vue b/aleksis/core/assets/components/notifications/NotificationItem.vue index b1dea65c167df9417803a6e7c532309acce75325..6011765afe2ebf3df707d2dbfe7549e4f010d4a8 100644 --- a/aleksis/core/assets/components/notifications/NotificationItem.vue +++ b/aleksis/core/assets/components/notifications/NotificationItem.vue @@ -1,6 +1,6 @@ <template> <ApolloMutation - :mutation="require('./markNotificationRead.graphql')" + :mutation="gqlmarkNotificationRead" :variables="{ id: this.notification.id }" > <template #default="{ mutate, loading, error }"> @@ -80,6 +80,8 @@ </template> <script> +import gqlMarkNotificationRead from "./markNotificationRead.graphql"; + export default { props: { notification: { diff --git a/aleksis/core/assets/components/notifications/NotificationList.vue b/aleksis/core/assets/components/notifications/NotificationList.vue index 75493f837c9ba6a799c1e646d90fb4b5478cbcd0..b0e4a50a01112186da8627200bfab410215379bf 100644 --- a/aleksis/core/assets/components/notifications/NotificationList.vue +++ b/aleksis/core/assets/components/notifications/NotificationList.vue @@ -1,6 +1,6 @@ <template> <ApolloQuery - :query="require('./myNotifications.graphql')" + :query="gqlMyNotifications" :poll-interval="1000" > <template #default="{ result: { error, data, loading } }"> @@ -74,6 +74,7 @@ <script> import NotificationItem from "./NotificationItem.vue"; +import gqlMyNotifications from "./myNotifications.graphql"; export default { components: { diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js index 0cc741d08ec259cdde64be42daece4646b816cae..a761522fcc74fda66d8207749aad58d7c6b4d423 100644 --- a/aleksis/core/assets/index.js +++ b/aleksis/core/assets/index.js @@ -5,9 +5,17 @@ import "./app"; import LegacyBaseTemplate from "./components/LegacyBaseTemplate.vue"; import Parent from "./components/Parent.vue"; + +// This imports all known AlekSIS app entrypoints +// The list is generated by util/frontent_helpers.py and passed to Vite, +// which aliases the app package names into virtual JavaScript modules +// and generates importing code at bundle time. +import "aleksisAppImporter"; + import CeleryProgress from "./components/celery_progress/CeleryProgress.vue"; import About from "./components/about/About.vue"; + window.router.addRoute({ path: "/account/login/", name: "core.account.login", diff --git a/aleksis/core/management/commands/vite.py b/aleksis/core/management/commands/vite.py new file mode 100644 index 0000000000000000000000000000000000000000..57370441b37a9db241a8a8a0bebbd8f8f00f5753 --- /dev/null +++ b/aleksis/core/management/commands/vite.py @@ -0,0 +1,29 @@ +import os + +from django.conf import settings + +from django_yarnpkg.management.base import BaseYarnCommand +from django_yarnpkg.yarn import yarn_adapter + +from ...util.frontend_helpers import run_vite, write_vite_values + + +class Command(BaseYarnCommand): + help = "Create Vite bundles for AlekSIS" # noqa + + def add_arguments(self, parser): + parser.add_argument("command", choices=["build", "serve"], nargs="?", default="build") + parser.add_argument("--no-install", action="store_true", default=False) + + def handle(self, *args, **options): + super(Command, self).handle(*args, **options) + + # Inject settings into Vite + write_vite_values(os.path.join(settings.NODE_MODULES_ROOT, "django-vite-values.json")) + + # Install Node dependencies + if not options["no_install"]: + yarn_adapter.install(settings.YARN_INSTALLED_APPS) + + # Run Vite build + run_vite([options["command"]]) diff --git a/aleksis/core/management/commands/webpack_bundle.py b/aleksis/core/management/commands/webpack_bundle.py index ee38566a8cda8587c41aacf036eca1d7da16cd04..1b324ae200e993dba15119fb16335091217d7200 100644 --- a/aleksis/core/management/commands/webpack_bundle.py +++ b/aleksis/core/management/commands/webpack_bundle.py @@ -1,35 +1,16 @@ -import json -import os -import shutil +import warnings -from django.conf import settings +from .vite import Command as ViteCommand -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 +class Command(ViteCommand): + help = "Create Vite bundles for AlekSIS (legacy command alias)" # 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) + warnings.warn( + "webpack_bundle is deprecated and will be removed " + "in AlekSIS-Core 4.0. Use the new vite command instead.", + UserWarning, + ) - # Run webpack - config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "webpack.config.js") - shutil.copy(config_path, settings.NODE_MODULES_ROOT) - mode = "development" if settings.DEBUG else "production" - yarn_adapter.call_yarn(["run", "webpack", f"--mode={mode}"]) + super().handle(*args, **options) diff --git a/aleksis/core/migrations/0047_add_room_model.py b/aleksis/core/migrations/0047_add_room_model.py new file mode 100644 index 0000000000000000000000000000000000000000..2194017cf1678ab8c5bc4af61b7e7ed05e4cacf3 --- /dev/null +++ b/aleksis/core/migrations/0047_add_room_model.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.15 on 2022-11-20 14:20 + +from django.apps import apps +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('core', '0046_notification_create_field_icon'), + ] + + if "chronos" in apps.app_configs: + recorder = migrations.recorder + if not recorder.MigrationRecorder.Migration.objects.filter(app="core", name="0046_add_room_model").exists(): + dependencies.append(('chronos', '0012_add_supervision_global_permission')) + + operations = [ + migrations.CreateModel( + name='Room', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('short_name', models.CharField(max_length=255, verbose_name='Short name')), + ('name', models.CharField(max_length=255, verbose_name='Long name')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Room', + 'verbose_name_plural': 'Rooms', + 'ordering': ['name', 'short_name'], + 'permissions': (('view_room_timetable', 'Can view room timetable'),), + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddConstraint( + model_name='room', + constraint=models.UniqueConstraint(fields=('site_id', 'short_name'), name='unique_room_short_name_per_site'), + ), + # Migrate data from Chronos table; deletion will be handled by Chronos + migrations.RunSQL( + """ + -- Copy rooms from chronos if table exists + DO $$BEGIN INSERT INTO core_room SELECT * FROM chronos_room; EXCEPTION WHEN undefined_table THEN NULL; END$$; + """ + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 9bf3f0d12eb8bf7f9b9b7c03319647903acf6ea8..fff6d2cfa71a70b30d2f92a39f782ffc151c6927 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -1465,3 +1465,25 @@ class PersonalICalUrl(models.Model): def get_absolute_url(self): return reverse("ical_feed", kwargs={"slug": self.uuid}) + + +class Room(ExtensibleModel): + short_name = models.CharField(verbose_name=_("Short name"), max_length=255) + name = models.CharField(verbose_name=_("Long name"), max_length=255) + + def __str__(self) -> str: + return f"{self.name} ({self.short_name})" + + def get_absolute_url(self) -> str: + return reverse("timetable", args=["room", self.id]) + + class Meta: + permissions = (("view_room_timetable", _("Can view room timetable")),) + ordering = ["name", "short_name"] + verbose_name = _("Room") + verbose_name_plural = _("Rooms") + constraints = [ + models.UniqueConstraint( + fields=["site_id", "short_name"], name="unique_room_short_name_per_site" + ), + ] diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py index b2ab913d34b110bcd10819baee1da24e742f3d25..cd6ac2c6acf9a86b7999b36807762b4a04e0471f 100644 --- a/aleksis/core/schema/__init__.py +++ b/aleksis/core/schema/__init__.py @@ -9,7 +9,11 @@ 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 .celery_progress import ( + CeleryProgressFetchedMutation, + CeleryProgressMetaType, + CeleryProgressType, +) from .group import GroupType # noqa from .installed_apps import AppType from .message import MessageType @@ -44,15 +48,18 @@ class Query(graphene.ObjectType): messages = graphene.List(MessageType) def resolve_notifications(root, info, **kwargs): - # FIXME do permission stuff - return Notification.objects.all() + return NotificationType.get_queryset( + Notification.objects.all().order_by("-created"), + info, + ) def resolve_persons(root, info, **kwargs): - # FIXME do permission stuff - return Person.objects.all() + return PersonType.get_queryset(Person.objects.all(), info).all() def resolve_person_by_id(root, info, id): # noqa - return Person.objects.get(pk=id) + return PersonType.get_queryset( + Person.objects.filter(pk=id), info, "core.view_person_rule" + ).first() def resolve_who_am_i(root, info, **kwargs): if has_person(info.context.user): @@ -70,10 +77,13 @@ class Query(graphene.ObjectType): 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) + task = CeleryProgressMetaType.get_queryset( + TaskUserAssignment.objects.filter(task_result__task_id=task_id), + info, + ).first() - if not info.context.user.has_perm("core.view_progress_rule", task): - return None + if not task: + raise PermissionDenied() progress = task.get_progress_with_meta() return progress diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py new file mode 100644 index 0000000000000000000000000000000000000000..363c6b8ac849affcba2d9c1cbf9eefa74498121d --- /dev/null +++ b/aleksis/core/schema/base.py @@ -0,0 +1,14 @@ +from graphene_django import DjangoObjectType + +from ..util.core_helpers import queryset_rules_filter + + +class RulesObjectType(DjangoObjectType): + class Meta: + abstract = True + + @classmethod + def get_queryset(cls, queryset, info, perm): + q = super().get_queryset(queryset, info) + + return queryset_rules_filter(info.context, q, perm) diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py index 941d18f1d5e79db17a549d30dbb90f0c42310a25..c6fe93dd06b8e853d6d0b3f748ad1d6b2b4534b5 100644 --- a/aleksis/core/schema/celery_progress.py +++ b/aleksis/core/schema/celery_progress.py @@ -2,9 +2,9 @@ from django.contrib.messages.constants import DEFAULT_TAGS import graphene from graphene import ObjectType -from graphene_django import DjangoObjectType from ..models import TaskUserAssignment +from .base import RulesObjectType class CeleryProgressMessage(ObjectType): @@ -28,7 +28,7 @@ class CeleryProgressAdditionalButtonType(ObjectType): icon = graphene.String() -class CeleryProgressMetaType(DjangoObjectType): +class CeleryProgressMetaType(RulesObjectType): additional_button = graphene.Field(CeleryProgressAdditionalButtonType, required=False) task_id = graphene.String(required=True) @@ -47,6 +47,10 @@ class CeleryProgressMetaType(DjangoObjectType): "additional_button", ) + @classmethod + def get_queryset(cls, queryset, info, perm="core.view_progress_rule"): + return super().get_queryset(queryset, info, perm) + def resolve_additional_button(root, info, **kwargs): if not root.additional_button_title or not root.additional_button_url: return None diff --git a/aleksis/core/schema/group.py b/aleksis/core/schema/group.py index 327daff3dd09d54053b683ba4cefb71da8ba6e5b..70f00bd1d6793916a9c954747e6242a09a12b519 100644 --- a/aleksis/core/schema/group.py +++ b/aleksis/core/schema/group.py @@ -1,8 +1,11 @@ -from graphene_django import DjangoObjectType - from ..models import Group +from .base import RulesObjectType -class GroupType(DjangoObjectType): +class GroupType(RulesObjectType): class Meta: model = Group + + @classmethod + def get_queryset(cls, queryset, info, perm="core.view_groups_rule"): + return super().get_queryset(queryset, info, perm) diff --git a/aleksis/core/schema/notification.py b/aleksis/core/schema/notification.py index 114f92b32d9658208013144fe2d7c2d2b0157595..fe1fc19a499f7241fd2770a6a3d1f394bd4569b6 100644 --- a/aleksis/core/schema/notification.py +++ b/aleksis/core/schema/notification.py @@ -1,13 +1,17 @@ import graphene -from graphene_django import DjangoObjectType from ..models import Notification +from .base import RulesObjectType -class NotificationType(DjangoObjectType): +class NotificationType(RulesObjectType): class Meta: model = Notification + @classmethod + def get_queryset(cls, queryset, info, perm="core.view_notifications_rule"): + return super().get_queryset(queryset, info, perm) + class MarkNotificationReadMutation(graphene.Mutation): class Arguments: @@ -18,8 +22,10 @@ class MarkNotificationReadMutation(graphene.Mutation): @classmethod def mutate(cls, root, info, id): # noqa notification = Notification.objects.get(pk=id) - # FIXME permissions + + if not info.context.user.has_perm("core.mark_notification_as_read_rule", notification): + raise PermissionDenied() notification.read = True notification.save() - return notification + return MarkNotificationReadMutation(notification=notification) diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py index 2ab8918900e64571a282674ca1e9b326204ddd34..044d5922c989655f20e355f6904eb3f2292986a6 100644 --- a/aleksis/core/schema/person.py +++ b/aleksis/core/schema/person.py @@ -3,11 +3,11 @@ from typing import Union from django.utils import timezone import graphene -from graphene_django import DjangoObjectType from graphene_django.forms.mutation import DjangoModelFormMutation from ..forms import PersonForm from ..models import DummyPerson, Person +from .base import RulesObjectType class FieldFileType(graphene.ObjectType): @@ -24,7 +24,7 @@ class PersonPreferencesType(graphene.ObjectType): return parent["theme__design"] -class PersonType(DjangoObjectType): +class PersonType(RulesObjectType): class Meta: model = Person @@ -58,9 +58,23 @@ class PersonType(DjangoObjectType): def resolve_notifications(root: Person, info, **kwargs): return root.notifications.filter(send_at__lte=timezone.now()).order_by("read", "-created") + @classmethod + def get_queryset(cls, queryset, info, perm="core.view_persons_rule"): + return super().get_queryset(queryset, info, perm) + class PersonMutation(DjangoModelFormMutation): person = graphene.Field(PersonType) class Meta: form_class = PersonForm + + @classmethod + def perform_mutate(cls, form, info): + if form.initial: + if not info.context.user.has_perm("core.create_person.rule"): + raise PermissionDenied() + else: + if not info.context.user.has_perm("core.edit_person.rule", form.instance): + raise PermissionDenied() + return super().perform_mutate(form, info) diff --git a/aleksis/core/search_indexes.py b/aleksis/core/search_indexes.py index 7583a774eaadebb1cbfdb35d08dc0ddcfb355eea..ea0b4dae56290791bf9f99c919aa6d2d7925b64e 100644 --- a/aleksis/core/search_indexes.py +++ b/aleksis/core/search_indexes.py @@ -1,4 +1,4 @@ -from .models import Group, Person +from .models import Group, Person, Room from .util.search import Indexable, SearchIndex @@ -12,3 +12,9 @@ class GroupIndex(SearchIndex, Indexable): """Haystack index for searching groups.""" model = Group + + +class RoomIndex(SearchIndex, Indexable): + """Haystack index for searching rooms.""" + + model = Room diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 499ca24baacd1bf11308dd8ea33749ecc30bfaeb..119325431a67baef0abe0f0d9cf923557e39b45b 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -97,7 +97,7 @@ INSTALLED_APPS = [ "sass_processor", "django_any_js", "django_yarnpkg", - "webpack_loader", + "django_vite", "django_tables2", "maintenance_mode", "menu_generator", @@ -572,22 +572,17 @@ YARN_INSTALLED_APPS = [ "vue-apollo@^3.1.0", "vuetify@^2.6.7", "vue-router@^3.5.2", - "css-loader@^6.7.1", - "sass-loader@^13.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", + "vite@^4.0.1", + "@vitejs/plugin-vue2@^2.2.0", + "@rollup/plugin-node-resolve@^15.0.1", + "@rollup/plugin-graphql@^2.0.2", + "@rollup/plugin-virtual@^3.0.1", "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", ] @@ -596,17 +591,12 @@ 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", 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"], - } -} +DJANGO_VITE_ASSETS_PATH = os.path.join(NODE_MODULES_ROOT, "vite_bundles") +DJANGO_VITE_DEV_MODE = DEBUG +DJANGO_VITE_DEV_SERVER_PORT = 5173 + STATICFILES_DIRS = ( - os.path.join(NODE_MODULES_ROOT, "webpack_bundles"), + DJANGO_VITE_ASSETS_PATH, JS_ROOT, ) @@ -755,6 +745,7 @@ if _settings.get("dev.uwsgi.celery", DEBUG): UWSGI.setdefault("attach-daemon", []) UWSGI["attach-daemon"].append(f"celery -A aleksis.core worker --concurrency={concurrency}") UWSGI["attach-daemon"].append("celery -A aleksis.core beat") + UWSGI["attach-daemon"].append("aleksis-admin vite --no-install serve") DEFAULT_FAVICON_PATHS = { "pwa_icon": os.path.join(STATIC_ROOT, "img/aleksis-icon-maskable.png"), diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html index f79c4a9bd7dfde9bcfcb5a2f822da5643054dca4..c1383da534105e30ccbb3ac09e67e35e1e80df15 100644 --- a/aleksis/core/templates/core/vue_base.html +++ b/aleksis/core/templates/core/vue_base.html @@ -1,7 +1,7 @@ {# -*- engine:django -*- #} {% load i18n menu_generator static sass_tags any_js rules html_helpers %} -{% load render_bundle from webpack_loader %} +{% load django_vite %} {% get_current_language as LANGUAGE_CODE %} {% get_available_languages as LANGUAGES %} @@ -53,6 +53,8 @@ <script type="text/javascript" src="{% url 'config.js' %}"></script> {% include_js "iconify" %} + {% vite_hmr_client %} + {% block extra_head %}{% endblock %} </head> <body {% if no_menu %}class="without-menu"{% endif %}> @@ -209,7 +211,7 @@ {{ request.site.preferences.theme__primary|json_script:"primary-color" }} {{ request.site.preferences.theme__secondary|json_script:"secondary-color" }} <script type="text/javascript" src="{% static 'js/search.js' %}"></script> -{% render_bundle 'core' %} +{% vite_asset 'aleksis/core/assets/index.js' %} {% block extra_body %}{% endblock %} </body> </html> diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 37741bc62b5310d13a1a512d47cf4cd7f4125a90..358c092b7549892616c04996a0164941da84c840 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -9,6 +9,7 @@ from django.views.i18n import JavaScriptCatalog import calendarweek.django from ckeditor_uploader import views as ckeditor_uploader_views +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 diff --git a/aleksis/core/util/frontend_helpers.py b/aleksis/core/util/frontend_helpers.py index 664707500bda7ffde9d5d8456be39128cf55ff8f..4389cdd6d50b6651f12e0d20d904849195a94156 100644 --- a/aleksis/core/util/frontend_helpers.py +++ b/aleksis/core/util/frontend_helpers.py @@ -1,7 +1,12 @@ +import json import os +import shutil +from typing import Any, Optional, Sequence from django.conf import settings +from django_yarnpkg.yarn import yarn_adapter + from .core_helpers import get_app_module, get_app_packages @@ -17,6 +22,43 @@ def get_apps_with_assets(): return assets +def write_vite_values(out_path: str) -> dict[str, Any]: + vite_values = { + "static_url": settings.STATIC_URL, + } + # Write rollup entrypoints for all apps + vite_values["appEntrypoints"] = {} + for app, path in get_apps_with_assets().items(): + ep = os.path.join(path, "index.js") + if os.path.exists(ep): + vite_values["appEntrypoints"][app] = ep + # Add core entrypoint + vite_values["coreEntrypoint"] = os.path.join( + settings.BASE_DIR, "aleksis", "core", "assets", "index.js" + ) + + with open(out_path, "w") as out: + json.dump(vite_values, out) + + +def run_vite(args: Optional[Sequence[str]] = None) -> None: + args = list(args) if args else [] + + config_path = os.path.join(settings.BASE_DIR, "aleksis", "core", "vite.config.js") + shutil.copy(config_path, settings.NODE_MODULES_ROOT) + + mode = "development" if settings.DEBUG else "production" + args += ["-m", mode] + + log_level = settings.LOGGING["root"]["level"] + if settings.DEBUG or log_level == "DEBUG": + args.append("-d") + log_level = {"INFO": "info", "WARNING": "warn", "ERROR": "error"}.get(log_level, "silent") + args += ["-l", log_level] + + yarn_adapter.call_yarn(["run", "vite"] + args) + + def get_language_cookie(code: str) -> str: """Build a cookie string to set a new language.""" cookie_parts = [f"{settings.LANGUAGE_COOKIE_NAME}={code}"] diff --git a/aleksis/core/views.py b/aleksis/core/views.py index cabc9e1413209d83c184d6ae00b86e40e56cb2e1..6faf412f8e57d7a5e3455510a2c82728696997bf 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -44,7 +44,6 @@ from django_celery_results.models import TaskResult from django_filters.views import FilterView from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView from dynamic_preferences.forms import preference_form_builder -from graphene_django.views import GraphQLView from guardian.shortcuts import GroupObjectPermission, UserObjectPermission, get_objects_for_user from haystack.generic_views import SearchView from haystack.inputs import AutoQuery @@ -96,7 +95,6 @@ from .models import ( DashboardWidget, DashboardWidgetOrder, DataCheckResult, - DummyPerson, Group, GroupType, OAuthApplication, @@ -213,16 +211,23 @@ def index(request: HttpRequest) -> HttpResponse: if has_person(request.user): person = request.user.person widgets = person.dashboard_widgets + activities = person.activities.all().order_by("-created")[:5] + notifications = person.notifications.filter(send_at__lte=timezone.now()).order_by( + "-created" + )[:5] + unread_notifications = person.notifications.filter( + send_at__lte=timezone.now(), read=False + ).order_by("-created") + announcements = Announcement.objects.at_time().for_person(person) + activities = person.activities.all().order_by("-created")[:5] + else: - person = DummyPerson() + person = None + activities = [] + notifications = [] + unread_notifications = [] widgets = [] - - activities = person.activities.all().order_by("-created")[:5] - - context["activities"] = activities - - announcements = Announcement.objects.at_time().for_person(person) - context["announcements"] = announcements + announcements = [] if len(widgets) == 0: # Use default dashboard if there are no widgets @@ -235,6 +240,8 @@ def index(request: HttpRequest) -> HttpResponse: context["widgets"] = widgets context["media"] = media context["show_edit_dashboard_button"] = show_edit_dashboard_button + context["activities"] = activities + context["announcements"] = announcements return render(request, "core/index.html", context) @@ -1589,7 +1596,3 @@ class ICalFeedCreateView(PermissionRequiredMixin, AdvancedCreateView): obj.person = self.request.user.person obj.save() return super().form_valid(form) - - -class PrivateGraphQLView(LoginRequiredMixin, GraphQLView): - """GraphQL view that requires a valid user session.""" diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..12a7bcc5baeec4f19645bc16bbfba556ace7f9f8 --- /dev/null +++ b/aleksis/core/vite.config.js @@ -0,0 +1,76 @@ +const fs = require("fs"); +const path = require("path"); + +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue2"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import graphql from "@rollup/plugin-graphql"; +import virtual from "@rollup/plugin-virtual"; + +const django_values = JSON.parse(fs.readFileSync("./django-vite-values.json")); + +function generateAppImporter(entrypoints) { + let code = "let appObjects = {};"; + for (const appPackage of Object.keys(entrypoints)) { + let appName = appPackage.split(".").slice(-1)[0]; + appName = appName.charAt(0).toUpperCase() + appName.substring(1); + + code += `console.debug("Importing AlekSIS app entrypoint for ${appPackage}");\n`; + code += `import ${appName} from '${appPackage}';\n`; + code += `appObjects.push(${appName});\n`; + } + code += "export default appObjects;\n"; + return code; +} + +export default defineConfig({ + root: path.resolve(".."), + base: django_values.static_url, + build: { + outDir: path.resolve("./vite_bundles/"), + manifest: true, + rollupOptions: { + input: django_values.coreEntrypoint, + output: { + manualChunks(id) { + // Split big libraries into own chunks + if (id.includes("node_modules/vue")) { + return "vue"; + } else if (id.includes("node_modules/apollo")) { + return "apollo"; + } else if (id.includes("node_modules/graphql")) { + return "graphql"; + } else if (id.includes("node_modules")) { + // Fallback for all other libraries + return "vendor"; + } + + // Split each AlekSIS app in its own chunk + for (const [appPackage, ep] of Object.entries(django_values.appEntrypoints)) { + if (id.includes(ep)) { + return appPackage; + } + } + }, + }, + }, + }, + server: { + strictPort: true, + origin: "http://127.0.0.1:5173", + }, + plugins: [ + virtual({ + aleksisAppImporter: generateAppImporter(django_values.appEntrypoints), + }), + vue(), + nodeResolve({ modulePaths: [path.resolve("./node_modules")] }), + graphql(), + ], + resolve: { + alias: { + vue: "vue/dist/vue.esm.js", + ...django_values.appEntrypoints, + }, + }, +}); diff --git a/aleksis/core/webpack.config.js b/aleksis/core/webpack.config.js deleted file mode 100644 index 8f327cbe55a6dba6b7ae5f104a89f4da46ddf711..0000000000000000000000000000000000000000 --- a/aleksis/core/webpack.config.js +++ /dev/null @@ -1,97 +0,0 @@ -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")), - output: { - path: path.resolve("./webpack_bundles/"), - filename: "[name]-[hash].js", - chunkFilename: "[id]-[chunkhash].js", - }, - plugins: [ - 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", - options: { - transpileOptions: { - transforms: { - dangerousTaggedTemplateString: true, - }, - }, - }, - }, - }, - { - test: /\.(css)$/, - use: ["vue-style-loader", "css-loader"], - }, - { - test: /\.scss$/, - use: [ - "vue-style-loader", - "css-loader", - { - loader: "sass-loader", - options: { - sassOptions: { - indentedSyntax: false, - }, - }, - }, - ], - }, - { - 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 ee46193d93282c9e44016b3b0ebf8e1f1b9c4b36..322d39023183a171b29f2fffc4a55988441a89c7 100644 --- a/docs/admin/10_install.rst +++ b/docs/admin/10_install.rst @@ -144,7 +144,7 @@ After that, you can install the aleksis meta-package, or only `aleksis-core`: .. code-block:: shell pip3 install aleksis - aleksis-admin webpack_bundle + aleksis-admin vite build aleksis-admin collectstatic aleksis-admin migrate aleksis-admin createinitialrevisions diff --git a/pyproject.toml b/pyproject.toml index c9a5f25b70fabae18809434113aeed5cdef4a1d7..0ab9b866ddfe0cf79255cb41de68747973452c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,8 +128,8 @@ django-ical = "^1.8.3" django-iconify = "^0.3" customidenticon = "^0.1.5" graphene-django = "^3.0.0" -django-webpack-loader = "^1.6.0" selenium = "^4.4.3" +django-vite = "^2.0.2" [tool.poetry.extras] ldap = ["django-auth-ldap"] diff --git a/tox.ini b/tox.ini index ff7a8c4aa3f0d97c2224ac9e4998fdaa246265c7..0c422a187839c40b718427898e2de2fab1c80758 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ skip_install = true envdir = {toxworkdir}/globalenv commands_pre = poetry install -E ldap - poetry run aleksis-admin webpack_bundle + poetry run aleksis-admin vite build poetry run aleksis-admin collectstatic --no-input commands = poetry run pytest --cov=. {posargs} aleksis/