Skip to content
Snippets Groups Projects
Commit 3263a6ca authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch 'release-3.0' into '788-regional-tel-format-isn-t-accepted'

# Conflicts:
#   CHANGELOG.rst
parents 2adaa40a 1cc07701
No related branches found
No related tags found
3 merge requests!1237Release 3.0,!1189Resolve "Regional tel format isn't accepted",!1183Release 3.0
Pipeline #124081 failed
Showing
with 255 additions and 41 deletions
......@@ -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
---------------------
......
......@@ -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,
)
......@@ -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",
......
......@@ -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
......
<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>
......@@ -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": {
......
......@@ -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")
......
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()
......@@ -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(
......
......@@ -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)
......
......@@ -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>
......
......@@ -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 #}
......
......@@ -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 %}
......@@ -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"
......
......@@ -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(
......
......@@ -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"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment