diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 65b8f70a13a3fad79b52003389809f3a478d4674..b8edc13c13aa3b760eecf77254c689b85cc3fcec 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,8 @@ Added * GraphQL schema for Rooms * [Dev] UpdateIndicator Vue Component to display the status of interactive pages +* [Dev] DeleteDialog Vue Component to unify item deletion in the new frontend + Changed ~~~~~~~ @@ -28,7 +30,14 @@ Fixed * 404 page was sometimes shown while the page was still loading. * Setting of page height in the iframe was not working correctly. * App switched to offline state when the user was logged out/in. -* Phone numbers couldn't be regional. +* The `Stop Impersonation` button is not shown due to an oversee when changing the type of the whoAmI query to an object of UserType +* Offline fallback page for legacy pages was misleading sometimes. +* Route changes in the Legacy-Component iframe didn't trigger a scroll to the top +* Query strings did not get passed when navigating legacy pages inside of the SPA. +* Retry button on error 500 page did not trigger a reload of the page. +* When the Celery worker wasn't able to execute all tasks in time, notifications were sent multiple times. +* Changing the maintenance mode state spawned another SPA instance in the iframe +* Phone numbers couldn't be in regional format. `3.0b3`_ - 2023-03-19 --------------------- diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 92c120886e4a530cc566b8489cb09d3e38f5ba2e..efd6e34edae2dd9f6e01e02cc9c8c9a669ce7cd0 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -19,6 +19,7 @@ from django_select2.forms import ModelSelect2MultipleWidget, ModelSelect2Widget, from dynamic_preferences.forms import PreferenceForm from guardian.shortcuts import assign_perm from invitations.forms import InviteForm +from maintenance_mode.core import get_maintenance_mode from material import Fieldset, Layout, Row from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm @@ -860,3 +861,11 @@ class OAuthApplicationForm(forms.ModelForm): "redirect_uris", "skip_authorization", ) + + +class MaintenanceModeForm(forms.Form): + maintenance_mode = forms.BooleanField( + required=False, + initial=not get_maintenance_mode(), + widget=forms.HiddenInput, + ) diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue index 30ef189b7daa037bc47fe711515e112b8391b377..790fc10c592bac5acc1e3bbadabcae63e065804f 100644 --- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue +++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue @@ -59,13 +59,14 @@ export default { const location = this.$refs.contentIFrame.contentWindow.location; const url = new URL(location); const path = url.pathname.replace(/^\/django/, ""); + const pathWithQueryString = path + encodeURI(url.search); const routePath = path.charAt(path.length - 1) === "/" && this.$route.path.charAt(path.length - 1) !== "/" ? this.$route.path + "/" : this.$route.path; if (path !== routePath) { - this.$router.push(path); + this.$router.push(pathWithQueryString); } // Show loader if iframe starts to change its content, even if the $route stays the same @@ -103,6 +104,10 @@ export default { $route() { // Show loading animation once route changes this.$root.contentLoading = true; + + // Scroll to top only when route changes to not affect form submits etc. + // A small duration to avoid flashing of the UI + this.$vuetify.goTo(0, { duration: 10 }); }, }, name: "LegacyBaseTemplate", diff --git a/aleksis/core/frontend/components/app/whoAmI.graphql b/aleksis/core/frontend/components/app/whoAmI.graphql index 12235e0ed7c320f46f0442c23e4a75dbba44c6cd..0b2877bd2cf15b5134c8e70978fb6b2218414e3a 100644 --- a/aleksis/core/frontend/components/app/whoAmI.graphql +++ b/aleksis/core/frontend/components/app/whoAmI.graphql @@ -3,6 +3,7 @@ query ($permissions: [String]!) { username isAuthenticated isAnonymous + isImpersonate person { photo { url @@ -10,7 +11,6 @@ query ($permissions: [String]!) { fullName avatarUrl isDummy - isImpersonate } permissions: globalPermissionsByName(permissions: $permissions) { name diff --git a/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..a56e3960cd1ae29e60f966a44ae69adeb0332949 --- /dev/null +++ b/aleksis/core/frontend/components/generic/dialogs/DeleteDialog.vue @@ -0,0 +1,136 @@ +<template> + <ApolloMutation + v-if="dialogOpen" + :mutation="gqlMutation" + :variables="{ id: item.id }" + :update="update" + @done="close(true)" + > + <template #default="{ mutate, loading, error }"> + <v-dialog v-model="dialogOpen" max-width="500px"> + <v-card> + <v-card-title class="text-h5"> + <slot name="title"> + {{ $t("actions.confirm_deletion") }} + </slot> + </v-card-title> + <v-card-text> + <slot name="body"> + <p class="text-body-1">{{ nameOfObject }}</p> + </slot> + </v-card-text> + <v-card-actions> + <v-spacer></v-spacer> + <v-btn text @click="close(false)" :disabled="loading"> + <slot name="cancelContent"> + {{ $t("actions.cancel") }} + </slot> + </v-btn> + <v-btn + color="error" + text + @click="mutate" + :loading="loading" + :disabled="loading" + > + <slot name="deleteContent"> + {{ $t("actions.delete") }} + </slot> + </v-btn> + </v-card-actions> + </v-card> + </v-dialog> + <v-snackbar :value="error !== null"> + {{ error }} + + <template #action="{ attrs }"> + <v-btn color="primary" text v-bind="attrs" @click="error = null" icon> + <v-icon>$close</v-icon> + </v-btn> + </template> + </v-snackbar> + </template> + </ApolloMutation> +</template> + +<script> +export default { + name: "DeleteDialog", + computed: { + nameOfObject() { + return this.itemAttribute in this.item || {} + ? this.item[this.itemAttribute] + : this.item.toString(); + }, + dialogOpen: { + get() { + return this.value; + }, + + set(val) { + this.$emit("input", val); + }, + }, + }, + methods: { + update(store) { + if (!this.gqlQuery) { + // There is no GraphQL query to update + return; + } + + // Read the data from cache for query + const storedData = store.readQuery({ query: this.gqlQuery }); + + if (!storedData) { + // There are no data in the cache yet + return; + } + + const storedDataKey = Object.keys(storedData)[0]; + + // Remove item from stored data + const index = storedData[storedDataKey].findIndex( + (m) => m.id === this.item.id + ); + storedData[storedDataKey].splice(index, 1); + + // Write data back to the cache + store.writeQuery({ query: this.gqlQuery, data: storedData }); + }, + close(success) { + this.$emit("input", false); + if (success) { + this.$emit("success"); + } else { + this.$emit("cancel"); + } + }, + }, + props: { + value: { + type: Boolean, + required: true, + }, + item: { + type: Object, + required: false, + default: () => ({}), + }, + itemAttribute: { + type: String, + required: false, + default: "name", + }, + gqlMutation: { + type: Object, + required: true, + }, + gqlQuery: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json index ff9693d369fea8f9ce44e07d5cc1dd4b89aae1aa..5235e23265346a5842cccf793e67987459a5b40f 100644 --- a/aleksis/core/frontend/messages/en.json +++ b/aleksis/core/frontend/messages/en.json @@ -75,7 +75,12 @@ "back": "Back", "search": "Search", "edit": "Edit", - "close": "Close" + "close": "Close", + "cancel": "Cancel", + "confirm_deletion": "Are you sure you want to delete this item?", + "delete": "Delete", + "stop_editing": "Stop editing", + "save": "Save" }, "administration": { "backend_admin": { diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 1a370c2c68af1569f92a9cc09ada7f8b5f671649..964306358262c94687f53400b4adc134bf2ef118 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -769,7 +769,7 @@ class Notification(ExtensibleModel, TimeStampedModel): def send(self, resend: bool = False) -> Optional[AsyncResult]: """Send the notification to the recipient.""" if not self.sent or resend: - return send_notification.delay(self.pk, resend=True) + return send_notification.delay(self.pk, resend=resend) class Meta: verbose_name = _("Notification") diff --git a/aleksis/core/schema/base.py b/aleksis/core/schema/base.py index e927dadccaaab7b2bf39c2110286970222f70f5b..e3478a34ff62be4859d8d9e88a8d73322796ad82 100644 --- a/aleksis/core/schema/base.py +++ b/aleksis/core/schema/base.py @@ -1,3 +1,6 @@ +from django.db.models import Model +from django.core.exceptions import PermissionDenied + import graphene from graphene_django import DjangoObjectType @@ -24,3 +27,23 @@ class FieldFileType(graphene.ObjectType): def resolve_absolute_url(root, info, **kwargs): return info.context.build_absolute_uri(root.url) if root else "" + + +class DeleteMutation(graphene.Mutation): + """Mutation to delete an object.""" + + klass: Model = None + permission_required: str = "" + ok = graphene.Boolean() + + class Arguments: + id = graphene.ID() # noqa + + @classmethod + def mutate(cls, root, info, **kwargs): + obj = cls.klass.objects.get(pk=kwargs["id"]) + if info.context.user.has_perm(cls.permission_required, obj): + obj.delete() + return cls(ok=True) + else: + raise PermissionDenied() diff --git a/aleksis/core/schema/person.py b/aleksis/core/schema/person.py index d5bdb6096b033e4be9a5f3c24565cb695d0e6890..6eed201487348034b2d9b1cba616046b1794dbf2 100644 --- a/aleksis/core/schema/person.py +++ b/aleksis/core/schema/person.py @@ -10,7 +10,7 @@ from guardian.shortcuts import get_objects_for_user from ..forms import PersonForm from ..models import DummyPerson, Person -from ..util.core_helpers import get_site_preferences, has_person, is_impersonate +from ..util.core_helpers import get_site_preferences, has_person from .base import FieldFileType from .notification import NotificationType @@ -65,7 +65,6 @@ class PersonType(DjangoObjectType): unread_notifications_count = graphene.Int() is_dummy = graphene.Boolean() - is_impersonate = graphene.Boolean() preferences = graphene.Field(PersonPreferencesType) can_edit_person = graphene.Boolean() @@ -199,9 +198,6 @@ class PersonType(DjangoObjectType): def resolve_is_dummy(root: Union[Person, DummyPerson], info, **kwargs): return root.is_dummy if hasattr(root, "is_dummy") else False - def resolve_is_impersonate(root: Person, info, **kwargs): - return is_impersonate(info.context) - def resolve_notifications(root: Person, info, **kwargs): if has_person(info.context.user) and info.context.user.person == root: return root.notifications.filter(send_at__lte=timezone.now()).order_by( diff --git a/aleksis/core/schema/user.py b/aleksis/core/schema/user.py index 1c1ff08ea2c4c77a6ab68af9e389a36db66a764c..7104a64b6acfa4f0891a9371304df9609569723d 100644 --- a/aleksis/core/schema/user.py +++ b/aleksis/core/schema/user.py @@ -12,6 +12,7 @@ class UserType(graphene.ObjectType): is_authenticated = graphene.Boolean(required=True) is_anonymous = graphene.Boolean(required=True) + is_impersonate = graphene.Boolean() person = graphene.Field(PersonType) diff --git a/aleksis/core/templates/500.html b/aleksis/core/templates/500.html index d008cd5405f4e5eee73ddf175cab87057e563785..a2ea5c902dba89f34241403e85d0e8af8d8f9a5d 100644 --- a/aleksis/core/templates/500.html +++ b/aleksis/core/templates/500.html @@ -16,7 +16,7 @@ {% endblocktrans %} </p> {% include "core/partials/admins_list.html" %} - <a href="javascript:window.location.reload()" class="btn green waves-effect waves-light"> + <a onClick="window.location.reload();" class="btn secondary waves-effect waves-light"> <i class="material-icons left">refresh</i> {% trans "Retry" %} </a> diff --git a/aleksis/core/templates/core/pages/system_status.html b/aleksis/core/templates/core/pages/system_status.html index 7f0e7bff0840029b500620662cd4681ac6bddded..00c0010d9f150fbd6e0b6bd3cf7f60684c08b350 100644 --- a/aleksis/core/templates/core/pages/system_status.html +++ b/aleksis/core/templates/core/pages/system_status.html @@ -13,30 +13,34 @@ {# Maintenance mode #} <div class="row"> - {% if maintenance_mode %} - <a class="btn-flat btn-flat-medium right waves-effect waves-red no-padding" - href="{% url 'maintenance_mode_off' %}"> - <i class="material-icons iconify small red-text center" data-icon="mdi:power"></i> - </a> - <div> - <p class="flow-text">{% blocktrans %}Maintenance mode enabled{% endblocktrans %}</p> - <p class="grey-text"> - {% blocktrans %} - Only admin and visitors from internal IPs can access the site. - {% endblocktrans %} - </p> - </div> - <span class="badge badge-danger mdi mdi-power"><a href="{% url 'maintenance_mode_off' %}"></a></span> - {% else %} - <a class="btn-flat btn-flat-medium right waves-effect waves-green no-padding" - href="{% url 'maintenance_mode_on' %}"> - <i class="material-icons iconify small green-text center" data-icon="mdi:power"></i> - </a> - <div> - <p class="flow-text">{% blocktrans %}Maintenance mode disabled{% endblocktrans %}</p> - <p class="grey-text">{% blocktrans %}Everyone can access the site.{% endblocktrans %}</p> - </div> - {% endif %} + <form method="POST"> + {% csrf_token %} + {{ form }} + + {% if maintenance_mode %} + <button class="btn-flat btn-flat-medium right waves-effect waves-red no-padding" + type="submit"> + <i class="material-icons iconify small red-text center" data-icon="mdi:power"></i> + </button> + <div> + <p class="flow-text">{% blocktrans %}Maintenance mode enabled{% endblocktrans %}</p> + <p class="grey-text"> + {% blocktrans %} + Only admin and visitors from internal IPs can access the site. + {% endblocktrans %} + </p> + </div> + {% else %} + <button class="btn-flat btn-flat-medium right waves-effect waves-green no-padding" + type="submit"> + <i class="material-icons iconify small green-text center" data-icon="mdi:power"></i> + </button> + <div> + <p class="flow-text">{% blocktrans %}Maintenance mode disabled{% endblocktrans %}</p> + <p class="grey-text">{% blocktrans %}Everyone can access the site.{% endblocktrans %}</p> + </div> + {% endif %} + </form> </div> {# Debug mode #} diff --git a/aleksis/core/templates/offline.html b/aleksis/core/templates/offline.html index 77537353bc1615426bab6e836519954c4a803da6..8bb50d53c202d2688e8c0da947b080cad1c52f28 100644 --- a/aleksis/core/templates/offline.html +++ b/aleksis/core/templates/offline.html @@ -7,15 +7,18 @@ {% block content %} <h3> <i class="material-icons iconify left medium" style="font-size: 2.92rem;" data-icon="mdi:wifi-strength-alert-outline"></i> - {% blocktrans %}Page not available offline.{% endblocktrans %} + {% blocktrans %}No connection to server.{% endblocktrans %} </h3> <p class="flow-text"> {% blocktrans %} - This page is not available offline. Since you probably don't have an internet connection, check to see if your WiFi - or mobile data is turned on and try again. If you think you are connected, please contact the system - administrators: + This page is not available without a connection to the server. Please check your internet connection and try again. + If you are connected and the error persists, please contact the system administrators: {% endblocktrans %} </p> {% include "core/partials/admins_list.html" %} + <a onClick="window.location.reload();" class="btn secondary waves-effect waves-light"> + <i class="material-icons left">refresh</i> + {% trans "Retry" %} + </a> {% endblock %} diff --git a/aleksis/core/views.py b/aleksis/core/views.py index e9536003df379d5f97d1cf895d27c2a7b1623600..3fd874f4f920ca871083c34a11e921e6181742b3 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -16,6 +16,7 @@ from django.http import ( Http404, HttpRequest, HttpResponse, + HttpResponseBadRequest, HttpResponseRedirect, HttpResponseServerError, JsonResponse, @@ -52,6 +53,7 @@ from haystack.query import SearchQuerySet from haystack.utils.loading import UnifiedIndex from health_check.views import MainView from invitations.views import SendInvite +from maintenance_mode.core import set_maintenance_mode from oauth2_provider.exceptions import OAuthToolkitError from oauth2_provider.models import get_application_model from oauth2_provider.views import AuthorizationView @@ -85,6 +87,7 @@ from .forms import ( EditGroupTypeForm, GroupPreferenceForm, InvitationCodeForm, + MaintenanceModeForm, OAuthApplicationForm, PersonForm, PersonPreferenceForm, @@ -505,9 +508,29 @@ class SystemStatus(PermissionRequiredMixin, MainView): "status_code": status_code, "tasks": task_results, "DEBUG": settings.DEBUG, + "form": MaintenanceModeForm(), } return self.render_to_response(context, status=status_code) + def post(self, request, *args, **kwargs): + form = MaintenanceModeForm(request.POST) + + if form.is_valid(): + mode = form.cleaned_data.get("maintenance_mode") + else: + return HttpResponseBadRequest() + + if not request.user.is_superuser: + return self.handle_no_permission() + + set_maintenance_mode(mode) + if mode: + messages.success(request, _("Maintenance mode was turned on successfully.")) + else: + messages.success(request, _("Maintenance mode was turned off successfully.")) + + return self.get(request, *args, **kwargs) + class TestPDFGenerationView(PermissionRequiredMixin, RenderPDFView): template_name = "core/pages/test_pdf.html" diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index 604dbdae5410acc61fb5699b9a797c5906ee69de..287534f096c186bc428de742c4e5ff56b71e8eab 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -239,7 +239,7 @@ export default defineConfig({ plugins: [ { fetchDidSucceed: async ({ request, response }) => { - if (response.status < 400) { + if (response.status < 500) { return response; } throw new Error( diff --git a/pyproject.toml b/pyproject.toml index 9a3318eaef990a7445fd14bcbabad0885b1ac6b2..e4de1fd18c27222094738b4ff926335dc9f9a3c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ django-favicon-plus-reloaded = "^1.1.5" django-health-check = "^3.12.1" psutil = "^5.7.0" celery-progress = "^0.1.0" -django-cachalot = "^2.3.2" +django-cachalot = "^2.5.3" django-prometheus = "^2.1.0" django-model-utils = "^4.0.0" bs4 = "^0.0.1"