diff --git a/aleksis/core/assets/app.js b/aleksis/core/assets/app.js
index 148612e2951728137b26e6a93cd98c0cf1112129..3fda256d7740305515809b037d1868d4e7792305 100644
--- a/aleksis/core/assets/app.js
+++ b/aleksis/core/assets/app.js
@@ -83,6 +83,7 @@ import LanguageForm from "./components/LanguageForm.vue";
 import MessageBox from "./components/MessageBox.vue";
 import NotificationList from "./components/notifications/NotificationList.vue";
 import SidenavSearch from "./components/SidenavSearch.vue";
+import CeleryProgressBottom from "./components/celery_progress/CeleryProgressBottom.vue";
 
 Vue.component(MessageBox.name, MessageBox); // Load MessageBox globally as other components depend on it
 
@@ -130,6 +131,7 @@ const app = new Vue({
     "language-form": LanguageForm,
     "notification-list": NotificationList,
     "sidenav-search": SidenavSearch,
+    CeleryProgressBottom,
   },
   router,
   i18n,
diff --git a/aleksis/core/assets/components/BackButton.vue b/aleksis/core/assets/components/BackButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..eaa305f7c3c8f2ca17f53f709870459e9cc790bd
--- /dev/null
+++ b/aleksis/core/assets/components/BackButton.vue
@@ -0,0 +1,12 @@
+<template>
+  <v-btn color="secondary" v-bind="$attrs">
+    <v-icon left>mdi-chevron-left</v-icon>
+    {{ $t("actions.back") }}
+  </v-btn>
+</template>
+
+<script>
+export default {
+  name: "BackButton",
+};
+</script>
diff --git a/aleksis/core/assets/components/SidenavSearch.vue b/aleksis/core/assets/components/SidenavSearch.vue
index 1c94ce9ef82ef7c0005bceab7d3280383a641f73..58bf7bf6d718302279cd9d2d5d9451b6bdf9a644 100644
--- a/aleksis/core/assets/components/SidenavSearch.vue
+++ b/aleksis/core/assets/components/SidenavSearch.vue
@@ -10,27 +10,59 @@ export default {
       type: String,
       required: true,
     },
-    placeholder: {
-      type: String,
-      required: true,
-    },
   },
   name: "SidenavSearch",
+  data() {
+    return {
+      q: "",
+    };
+  },
 };
-// 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>
+  <ApolloQuery
+    :query="require('./searchSnippets.graphql')"
+    :variables="{
+      q,
+    }"
+    :skip="!q"
+  >
+    <template #default="{ result: { error, data }, isLoading, query }">
+      <form method="get" ref="form" :action="action" id="search-form">
+        <input type="hidden" name="q" :value="q" />
+        <v-autocomplete
+          :prepend-icon="'mdi-magnify'"
+          append-icon=""
+          @click:prepend="submit"
+          single-line
+          clearable
+          :loading="!!isLoading"
+          id="search"
+          type="search"
+          enterkeyhint="search"
+          :label="$t('actions.search')"
+          :search-input.sync="q"
+          flat
+          solo
+          cache-items
+          hide-no-data
+          hide-details
+          :items="data ? data.searchSnippets : undefined"
+        >
+          <template #item="{ item }">
+            <v-list-item :href="item.obj.absoluteUrl">
+              <v-list-item-icon v-if="item.obj.icon">
+                <v-icon>{{ "mdi-" + item.obj.icon }}</v-icon>
+              </v-list-item-icon>
+              <v-list-item-content>
+                <v-list-item-title> {{ item.obj.name }}</v-list-item-title>
+                <v-list-item-subtitle>{{ item.text }}</v-list-item-subtitle>
+              </v-list-item-content>
+            </v-list-item>
+          </template>
+        </v-autocomplete>
+      </form>
+    </template>
+  </ApolloQuery>
 </template>
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgress.vue b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
new file mode 100644
index 0000000000000000000000000000000000000000..06b28fc37f52f5440442815207d901fe42cdcd69
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgress.vue
@@ -0,0 +1,130 @@
+<template>
+  <v-row>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+    <v-col sm="12" md="10" lg="8" xl="6">
+      <v-card :loading="$apollo.loading">
+        <v-card-title v-if="progress">
+          {{ progress.meta.title }}
+        </v-card-title>
+        <v-card-text v-if="progress">
+          <v-progress-linear
+            :value="progress.progress.percent"
+            buffer-value="0"
+            color="primary"
+            class="mb-2"
+            stream
+          />
+          <div class="text-center mb-4">
+            {{
+              progress.meta.progressTitle
+                ? progress.meta.progressTitle
+                : $t("celery_progress.progress_title")
+            }}
+          </div>
+          <div v-if="progress">
+            <message-box
+              v-for="(message, idx) in progress.messages"
+              dense
+              :type="message.tag"
+              transition="slide-x-transition"
+              :key="idx"
+            >
+              {{ message.message }}
+            </message-box>
+          </div>
+          <message-box
+            v-if="progress.state === 'ERROR'"
+            dense
+            type="error"
+            transition="slide-x-transition"
+          >
+            {{
+              progress.meta.errorMessage
+                ? progress.meta.errorMessage
+                : $t("celery_progress.error_message")
+            }}
+          </message-box>
+          <message-box
+            v-if="progress.state === 'SUCCESS'"
+            dense
+            type="success"
+            transition="slide-x-transition"
+          >
+            {{
+              progress.meta.successMessage
+                ? progress.meta.successMessage
+                : $t("celery_progress.success_message")
+            }}
+          </message-box>
+        </v-card-text>
+        <v-card-actions
+          v-if="
+            progress &&
+            (progress.state === 'ERROR' || progress.state === 'SUCCESS')
+          "
+        >
+          <back-button :href="progress.meta.backUrl" text />
+          <v-spacer />
+          <v-btn
+            v-if="progress.meta.additionalButton"
+            :href="progress.meta.additionalButton.url"
+            text
+            color="primary"
+          >
+            <v-icon v-if="progress.meta.additionalButton.icon" left>
+              {{ progress.meta.additionalButton.icon }}
+            </v-icon>
+            {{ progress.meta.additionalButton.title }}
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-col>
+    <v-col sm="0" md="1" lg="2" xl="3" />
+  </v-row>
+</template>
+
+<script>
+import BackButton from "../BackButton.vue";
+import MessageBox from "../MessageBox.vue";
+
+export default {
+  name: "CeleryProgress",
+  components: { BackButton, MessageBox },
+  apollo: {
+    celeryProgressByTaskId: {
+      query: require("./celeryProgress.graphql"),
+      variables() {
+        return {
+          taskId: this.$route.params.taskId,
+        };
+      },
+      pollInterval: 1000,
+    },
+  },
+  computed: {
+    progress() {
+      return this.celeryProgressByTaskId;
+    },
+    state() {
+      return this.progress ? this.progress.state : null;
+    },
+  },
+  watch: {
+    state(newState) {
+      if (newState === "SUCCESS" || newState === "ERROR") {
+        this.$apollo.queries.celeryProgressByTaskId.stopPolling();
+        this.$apollo.mutate({
+          mutation: require("./celeryProgressFetched.graphql"),
+          variables: {
+            taskId: this.$route.params.taskId,
+          },
+        });
+      }
+      if (newState === "SUCCESS" && this.progress.meta.redirectOnSuccessUrl) {
+        window.location.replace(this.progress.meta.redirectOnSuccessUrl);
+        // FIXME this.$router.push(this.progress.meta.redirectOnSuccessUrl);
+      }
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2bb95431cecd66bc68789bc1bfe7c5977b68cfb6
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/CeleryProgressBottom.vue
@@ -0,0 +1,53 @@
+<template>
+  <v-bottom-sheet :value="show" persistent hide-overlay max-width="400px">
+    <v-expansion-panels accordion v-model="open">
+      <v-expansion-panel>
+        <v-expansion-panel-header color="primary" class="white--text px-4">
+          {{
+            $tc("celery_progress.running_tasks", numberOfTasks, {
+              number: numberOfTasks,
+            })
+          }}
+        </v-expansion-panel-header>
+        <v-expansion-panel-content>
+          <div class="mx-n6 mb-n4" v-if="celeryProgressByUser">
+            <task-list-item
+              v-for="task in celeryProgressByUser"
+              :task="task"
+              :key="task.meta.taskId"
+            />
+          </div>
+        </v-expansion-panel-content>
+      </v-expansion-panel>
+    </v-expansion-panels>
+  </v-bottom-sheet>
+</template>
+
+<script>
+import TaskListItem from "./TaskListItem.vue";
+
+export default {
+  name: "CeleryProgressBottom",
+  components: { TaskListItem },
+  data() {
+    return { open: 0 };
+  },
+  computed: {
+    show() {
+      return this.celeryProgressByUser && this.celeryProgressByUser.length > 0;
+    },
+    numberOfTasks() {
+      if (!this.celeryProgressByUser) {
+        return 0;
+      }
+      return this.celeryProgressByUser.length;
+    },
+  },
+  apollo: {
+    celeryProgressByUser: {
+      query: require("./celeryProgressBottom.graphql"),
+      pollInterval: 1000,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/assets/components/celery_progress/TaskListItem.vue b/aleksis/core/assets/components/celery_progress/TaskListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a90236923904f11a37c624528b6dcfbc7b879aae
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/TaskListItem.vue
@@ -0,0 +1,37 @@
+<template>
+  <v-list-item
+    :key="task.meta.taskId"
+    :to="{ name: 'core.celery_progress', params: { taskId: task.meta.taskId } }"
+  >
+    <v-list-item-content>
+      <v-list-item-title>{{ task.meta.title }}</v-list-item-title>
+      <v-list-item-subtitle>{{ task.meta.progressTitle }}</v-list-item-subtitle>
+    </v-list-item-content>
+
+    <v-list-item-action>
+      <v-progress-circular
+        v-if="!task.complete"
+        color="primary"
+        :value="task.progress.percent"
+      ></v-progress-circular>
+      <v-icon size="32px" v-else-if="task.state === 'SUCCESS'" color="success"
+        >mdi-check-circle-outline</v-icon
+      >
+      <v-icon size="32px" v-else color="error">mdi-alert-circle-outline</v-icon>
+    </v-list-item-action>
+  </v-list-item>
+</template>
+
+<script>
+export default {
+  name: "TaskListItem",
+  props: {
+    task: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>
+
+<style scoped></style>
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgress.graphql b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..557e33517d4f3536e043ba0e64cb8c3f622741d2
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgress.graphql
@@ -0,0 +1,30 @@
+query ($taskId: String!) {
+  celeryProgressByTaskId(taskId: $taskId) {
+    state
+    success
+    progress {
+      current
+      total
+      percent
+    }
+    complete
+    messages {
+      level
+      message
+      tag
+    }
+    meta {
+      title
+      progressTitle
+      errorMessage
+      successMessage
+      redirectOnSuccessUrl
+      backUrl
+      additionalButton {
+        title
+        icon
+        url
+      }
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..5cae8f3baa46b14b4cf1031dc248d2dd7757b576
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgressBottom.graphql
@@ -0,0 +1,17 @@
+{
+  celeryProgressByUser {
+    state
+    success
+    progress {
+      current
+      total
+      percent
+    }
+    complete
+    meta {
+      taskId
+      title
+      progressTitle
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..b3fedc916e6a3851677f0fe7a3c322c9311a33e4
--- /dev/null
+++ b/aleksis/core/assets/components/celery_progress/celeryProgressFetched.graphql
@@ -0,0 +1,7 @@
+mutation ($taskId: String!) {
+  celeryProgressFetched(taskId: $taskId) {
+    celeryProgress {
+      state
+    }
+  }
+}
diff --git a/aleksis/core/assets/components/searchSnippets.graphql b/aleksis/core/assets/components/searchSnippets.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..65a686624b21d4aba3d925ad7d6c48b1ab185063
--- /dev/null
+++ b/aleksis/core/assets/components/searchSnippets.graphql
@@ -0,0 +1,10 @@
+query search($q: String!) {
+  searchSnippets(query: $q, limit: 5) {
+    obj {
+      name
+      absoluteUrl
+      icon
+    }
+    text
+  }
+}
diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js
index 416029ed7a943ba2c2d0f3b20dc1c6783f38b4e0..ea18ea4dbc7cd71e42a0062da2ab80a987b11c51 100644
--- a/aleksis/core/assets/index.js
+++ b/aleksis/core/assets/index.js
@@ -3,6 +3,17 @@ import "@mdi/font/css/materialdesignicons.css";
 import "./util";
 import "./app";
 
+import CeleryProgress from "./components/celery_progress/CeleryProgress.vue";
 import About from "./components/about/About.vue";
 
-window.router.addRoute({ path: "/about", component: About });
+window.router.addRoute({
+  path: "/celery_progress/:taskId",
+  component: CeleryProgress,
+  props: true,
+  name: "core.celery_progress",
+});
+window.router.addRoute({
+  path: "/about",
+  component: About,
+  name: "core.about",
+});
diff --git a/aleksis/core/assets/messages.json b/aleksis/core/assets/messages.json
index 3e1eb6879b650779e04d6c313f3c9868b592c6d5..551b8e93f8175a097d917b906596fcd2be75d2a6 100644
--- a/aleksis/core/assets/messages.json
+++ b/aleksis/core/assets/messages.json
@@ -7,6 +7,16 @@
     "alerts": {
       "page_cached": "This page may contain outdated information since there is no internet connection."
     },
+    "celery_progress": {
+      "progress_title": "Loading ...",
+      "error_message": "The operation couldn't be finished successfully.",
+      "success_message": "The operation has been finished successfully.",
+      "running_tasks": "1 running task | {number} running tasks"
+    },
+    "actions": {
+      "back": "Back",
+      "search": "Search"
+    },
     "about": {
       "about_aleksis": "About AlekSIS®",
       "licenced_under": "Licenced under",
@@ -35,6 +45,16 @@
     "alerts": {
       "page_cached": "Diese Seite enthält vielleicht veraltete Informationen, da es keine Internetverbindung gibt."
     },
+    "celery_progress": {
+      "progress_title": "Wird geladen ...",
+      "error_message": "Der Vorgang konnte nicht erfolgreich beendet werden.",
+      "success_message": "Der Vorgang wurde erfolgreich beendet.",
+      "running_tasks": "1 laufende Aufgabe | {number} laufende Aufgaben"
+    },
+    "actions": {
+      "back": "Zurück",
+      "search": "Suchen"
+    },
     "about": {
       "about_aleksis": "Über AlekSIS®",
       "licenced_under": "Lizensiert unter",
diff --git a/aleksis/core/migrations/0042_task_assignment_meta.py b/aleksis/core/migrations/0042_task_assignment_meta.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe70ebd2b6783e9f92a28239164144a01d1ccb3b
--- /dev/null
+++ b/aleksis/core/migrations/0042_task_assignment_meta.py
@@ -0,0 +1,62 @@
+# Generated by Django 3.2.15 on 2022-10-03 18:38
+
+from django.db import migrations, models
+import django.utils.timezone
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0041_update_gender_choices'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_icon',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Additional button icon'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_title',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Additional button title'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='additional_button_url',
+            field=models.URLField(blank=True, verbose_name='Additional button URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='back_url',
+            field=models.URLField(blank=True, verbose_name='Back URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='error_message',
+            field=models.TextField(blank=True, verbose_name='Error message'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='success_message',
+            field=models.TextField(blank=True, verbose_name='Success message'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='progress_title',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Progress title'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='redirect_on_success_url',
+            field=models.URLField(blank=True, verbose_name='Redirect on success URL'),
+        ),
+        migrations.AddField(
+            model_name='taskuserassignment',
+            name='title',
+            field=models.CharField(default='Data are processed', max_length=255, verbose_name='Title'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/aleksis/core/migrations/0043_task_assignment_result_fetched.py b/aleksis/core/migrations/0043_task_assignment_result_fetched.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6f0da02d60d78d1ed503ecadb29c45c06a3e645
--- /dev/null
+++ b/aleksis/core/migrations/0043_task_assignment_result_fetched.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.16 on 2022-11-02 19:35
+
+import django.utils.timezone
+from django.db import migrations, models
+
+import oauth2_provider.generators
+import oauth2_provider.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("core", "0042_task_assignment_meta"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="taskuserassignment",
+            name="result_fetched",
+            field=models.BooleanField(default=False, verbose_name="Result fetched"),
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index c6cee9a8c6bbefed3439eef156b685f5484e4a13..6b54f8e40a1873a9ba35379136bc0c9723889d93 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -127,7 +127,7 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
     """
 
     # Defines a material design icon associated with this type of model
-    icon_ = "radio_button_unchecked"
+    icon_ = "radiobox-blank"
 
     site = models.ForeignKey(
         Site, on_delete=models.CASCADE, default=settings.SITE_ID, editable=False
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 0bf42033887664c79f71b0223deea994d69c21c5..f01086c8cbe8c786ab5d2497df77e730212c7c1f 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -31,6 +31,7 @@ import jsonstore
 from cachalot.api import cachalot_disabled
 from cache_memoize import cache_memoize
 from celery.result import AsyncResult
+from celery_progress.backend import Progress
 from ckeditor.fields import RichTextField
 from django_celery_results.models import TaskResult
 from django_cte import CTEQuerySet, With
@@ -176,7 +177,7 @@ class Person(ExtensibleModel):
             ),
         ]
 
-    icon_ = "person"
+    icon_ = "account-outline"
 
     SEX_CHOICES = [("f", _("female")), ("m", _("male")), ("x", _("other"))]
 
@@ -504,7 +505,7 @@ class Group(SchoolTermRelatedExtensibleModel):
             ),
         ]
 
-    icon_ = "group"
+    icon_ = "account-multiple-outline"
 
     name = models.CharField(verbose_name=_("Long name"), max_length=255)
     short_name = models.CharField(
@@ -1259,6 +1260,21 @@ class TaskUserAssignment(ExtensibleModel):
         get_user_model(), on_delete=models.CASCADE, verbose_name=_("Task user")
     )
 
+    title = models.CharField(max_length=255, verbose_name=_("Title"))
+    back_url = models.URLField(verbose_name=_("Back URL"), blank=True)
+    progress_title = models.CharField(max_length=255, verbose_name=_("Progress title"), blank=True)
+    error_message = models.TextField(verbose_name=_("Error message"), blank=True)
+    success_message = models.TextField(verbose_name=_("Success message"), blank=True)
+    redirect_on_success_url = models.URLField(verbose_name=_("Redirect on success URL"), blank=True)
+    additional_button_title = models.CharField(
+        max_length=255, verbose_name=_("Additional button title"), blank=True
+    )
+    additional_button_url = models.URLField(verbose_name=_("Additional button URL"), blank=True)
+    additional_button_icon = models.CharField(
+        max_length=255, verbose_name=_("Additional button icon"), blank=True
+    )
+    result_fetched = models.BooleanField(default=False, verbose_name=_("Result fetched"))
+
     @classmethod
     def create_for_task_id(cls, task_id: str, user: "User") -> "TaskUserAssignment":
         # Use get_or_create to ensure the TaskResult exists
@@ -1267,6 +1283,45 @@ class TaskUserAssignment(ExtensibleModel):
             result, __ = TaskResult.objects.get_or_create(task_id=task_id)
         return cls.objects.create(task_result=result, user=user)
 
+    def get_progress(self) -> dict[str, any]:
+        """Get progress information for this task."""
+        progress = Progress(AsyncResult(self.task_result.task_id))
+        return progress.get_info()
+
+    def get_progress_with_meta(self) -> dict[str, any]:
+        """Get progress information for this task."""
+        progress = self.get_progress()
+        progress["meta"] = self
+        return progress
+
+    def create_notification(self) -> Optional[Notification]:
+        """Create a notification for this task."""
+        progress = self.get_progress()
+        if progress["state"] == "SUCCESS":
+            title = _("Background task completed successfully")
+            description = _("The background task '{}' has been completed successfully.").format(
+                self.title
+            )
+
+        elif progress["state"] == "FAILURE":
+            title = _("Background task failed")
+            description = _("The background task '{}' has failed.").format(self.title)
+        else:
+            # Task not yet finished
+            return
+
+        link = reverse("task_status", args=[self.task_result.task_id])
+
+        notification = Notification(
+            sender=_("Background task"),
+            recipient=self.user.person,
+            title=title,
+            description=description,
+            link=link,
+        )
+        notification.save()
+        return notification
+
     class Meta:
         verbose_name = _("Task user assignment")
         verbose_name_plural = _("Task user assignments")
diff --git a/aleksis/core/rules.py b/aleksis/core/rules.py
index e12744ff6df91c72f8ec541b8f0c0672d2ddf273..c5f2bc4d498064d687392b7e63057de50b5c1136 100644
--- a/aleksis/core/rules.py
+++ b/aleksis/core/rules.py
@@ -11,6 +11,7 @@ from .util.predicates import (
     is_current_person,
     is_group_owner,
     is_notification_recipient,
+    is_own_celery_task,
     is_site_preference_set,
 )
 
@@ -374,3 +375,6 @@ rules.add_perm("core.edit_ical_rule", edit_ical_predicate)
 
 delete_ical_predicate = edit_ical_predicate
 rules.add_perm("core.delete_ical_rule", delete_ical_predicate)
+
+view_progress_predicate = has_person & is_own_celery_task
+rules.add_perm("core.view_progress_rule", view_progress_predicate)
diff --git a/aleksis/core/schema/__init__.py b/aleksis/core/schema/__init__.py
index 24f08bbe6e7d8b8b37160d583ee3757d6c5f3d11..1f5a2fa8e0723e883378541185e11d0649b18898 100644
--- a/aleksis/core/schema/__init__.py
+++ b/aleksis/core/schema/__init__.py
@@ -1,14 +1,19 @@
 from django.apps import apps
 
 import graphene
+from haystack.inputs import AutoQuery
+from haystack.query import SearchQuerySet
+from haystack.utils.loading import UnifiedIndex
 
-from ..models import Notification, Person
+from ..models import Notification, Person, TaskUserAssignment
 from ..util.apps import AppConfig
-from ..util.core_helpers import get_app_module, get_app_packages, has_person
+from ..util.core_helpers import get_allowed_object_ids, get_app_module, get_app_packages, has_person
+from .celery_progress import CeleryProgressFetchedMutation, CeleryProgressType
 from .group import GroupType  # noqa
 from .installed_apps import AppType
 from .notification import MarkNotificationReadMutation, NotificationType
 from .person import PersonMutation, PersonType
+from .search import SearchResultType
 from .system_properties import SystemPropertiesType
 
 
@@ -24,6 +29,13 @@ class Query(graphene.ObjectType):
     system_properties = graphene.Field(SystemPropertiesType)
     installed_apps = graphene.List(AppType)
 
+    celery_progress_by_task_id = graphene.Field(CeleryProgressType, task_id=graphene.String())
+    celery_progress_by_user = graphene.List(CeleryProgressType)
+
+    search_snippets = graphene.List(
+        SearchResultType, query=graphene.String(), limit=graphene.Int(required=False)
+    )
+
     def resolve_notifications(root, info, **kwargs):
         # FIXME do permission stuff
         return Notification.objects.all()
@@ -47,12 +59,40 @@ class Query(graphene.ObjectType):
     def resolve_installed_apps(root, info, **kwargs):
         return [app for app in apps.get_app_configs() if isinstance(app, AppConfig)]
 
+    def resolve_celery_progress_by_task_id(root, info, task_id, **kwargs):
+        task = TaskUserAssignment.objects.get(task_result__task_id=task_id)
+
+        if not info.context.user.has_perm("core.view_progress_rule", task):
+            return None
+        progress = task.get_progress_with_meta()
+        return progress
+
+    def resolve_celery_progress_by_user(root, info, **kwargs):
+        tasks = TaskUserAssignment.objects.filter(user=info.context.user)
+        return [
+            task.get_progress_with_meta()
+            for task in tasks
+            if task.get_progress_with_meta()["complete"] is False
+        ]
+
+    def resolve_search_snippets(root, info, query, limit=-1, **kwargs):
+        indexed_models = UnifiedIndex().get_indexed_models()
+        allowed_object_ids = get_allowed_object_ids(info.context.user, indexed_models)
+        results = SearchQuerySet().filter(id__in=allowed_object_ids).filter(text=AutoQuery(query))
+
+        if limit < 0:
+            return results
+
+        return results[:limit]
+
 
 class Mutation(graphene.ObjectType):
     update_person = PersonMutation.Field()
 
     mark_notification_read = MarkNotificationReadMutation.Field()
 
+    celery_progress_fetched = CeleryProgressFetchedMutation.Field()
+
 
 def build_global_schema():
     """Build global GraphQL schema from all apps."""
diff --git a/aleksis/core/schema/celery_progress.py b/aleksis/core/schema/celery_progress.py
new file mode 100644
index 0000000000000000000000000000000000000000..941d18f1d5e79db17a549d30dbb90f0c42310a25
--- /dev/null
+++ b/aleksis/core/schema/celery_progress.py
@@ -0,0 +1,94 @@
+from django.contrib.messages.constants import DEFAULT_TAGS
+
+import graphene
+from graphene import ObjectType
+from graphene_django import DjangoObjectType
+
+from ..models import TaskUserAssignment
+
+
+class CeleryProgressMessage(ObjectType):
+    message = graphene.String(required=True)
+    level = graphene.Int(required=True)
+    tag = graphene.String(required=True)
+
+    def resolve_message(root, info, **kwargs):
+        return root[1]
+
+    def resolve_level(root, info, **kwargs):
+        return root[0]
+
+    def resolve_tag(root, info, **kwargs):
+        return DEFAULT_TAGS.get(root[0], "info")
+
+
+class CeleryProgressAdditionalButtonType(ObjectType):
+    title = graphene.String(required=True)
+    url = graphene.String(required=True)
+    icon = graphene.String()
+
+
+class CeleryProgressMetaType(DjangoObjectType):
+    additional_button = graphene.Field(CeleryProgressAdditionalButtonType, required=False)
+    task_id = graphene.String(required=True)
+
+    def resolve_task_id(root, info, **kwargs):
+        return root.task_result.task_id
+
+    class Meta:
+        model = TaskUserAssignment
+        fields = (
+            "title",
+            "back_url",
+            "progress_title",
+            "error_message",
+            "success_message",
+            "redirect_on_success_url",
+            "additional_button",
+        )
+
+    def resolve_additional_button(root, info, **kwargs):
+        if not root.additional_button_title or not root.additional_button_url:
+            return None
+        return {
+            "title": root.additional_button_title,
+            "url": root.additional_button_url,
+            "icon": root.additional_button_icon,
+        }
+
+
+class CeleryProgressProgressType(ObjectType):
+    current = graphene.Int()
+    total = graphene.Int()
+    percent = graphene.Float()
+
+
+class CeleryProgressType(graphene.ObjectType):
+    state = graphene.String()
+    complete = graphene.Boolean()
+    success = graphene.Boolean()
+    progress = graphene.Field(CeleryProgressProgressType)
+    messages = graphene.List(CeleryProgressMessage)
+    meta = graphene.Field(CeleryProgressMetaType)
+
+    def resolve_messages(root, info, **kwargs):  # noqa
+        if root["complete"] and isinstance(root["result"], list):
+            return root["result"]
+        return root["progress"].get("messages", [])
+
+
+class CeleryProgressFetchedMutation(graphene.Mutation):
+    class Arguments:
+        task_id = graphene.String(required=True)
+
+    celery_progress = graphene.Field(CeleryProgressType)
+
+    def mutate(root, info, task_id, **kwargs):
+        task = TaskUserAssignment.objects.filter(task_result__task_id=task_id)
+
+        if not info.context.user.has_perm("core.view_progress_rule", task):
+            return None
+        task.result_fetched = True
+        task.save()
+        progress = task.get_progress_with_meta()
+        return CeleryProgressFetchedMutation(celery_progress=progress)
diff --git a/aleksis/core/schema/search.py b/aleksis/core/schema/search.py
new file mode 100644
index 0000000000000000000000000000000000000000..290ac8818730e42e540c752362f5e2dcf5ea2840
--- /dev/null
+++ b/aleksis/core/schema/search.py
@@ -0,0 +1,32 @@
+import graphene
+
+
+class SearchModelType(graphene.ObjectType):
+    absolute_url = graphene.String()
+    name = graphene.String()
+    icon = graphene.String()
+
+    def resolve_absolute_url(root, info, **kwargs):
+        if hasattr(root, "get_absolute_url"):
+            return root.get_absolute_url()
+        else:
+            return "#!"
+
+    def resolve_name(root, info, **kwargs):
+        return str(root)
+
+    def resolve_icon(root, info, **kwargs):
+        return getattr(root, "icon_", "")
+
+
+class SearchResultType(graphene.ObjectType):
+    app_label = graphene.String()
+    model_name = graphene.String()
+    score = graphene.Int()
+    obj = graphene.Field(SearchModelType)
+    verbose_name = graphene.String()
+    verbose_name_plural = graphene.String()
+    text = graphene.String()
+
+    def resolve_obj(root, info, **kwargs):  # noqa
+        return root.object
diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js
index c8ebd1383f4e80f5ea93d4b59671974a5fdf3dac..8562b31f01d225e3f308117f78aa90ad3192f63b 100644
--- a/aleksis/core/static/js/main.js
+++ b/aleksis/core/static/js/main.js
@@ -163,7 +163,10 @@ $(document).ready(function () {
   });
 
   // Initialise auto-completion for search bar
-  window.autocomplete = new Autocomplete({ minimum_length: 2 });
+  window.autocomplete = new Autocomplete({
+    minimum_length: 2,
+    url: JSON.parse($("#search-snippet-url").text()),
+  });
   window.autocomplete.setup();
 
   // Initialize text collapsibles [MAT, own work]
diff --git a/aleksis/core/static/js/progress.js b/aleksis/core/static/js/progress.js
deleted file mode 100644
index dc554ec8f37cd6c8e649f5b1d72d503ad93a887f..0000000000000000000000000000000000000000
--- a/aleksis/core/static/js/progress.js
+++ /dev/null
@@ -1,100 +0,0 @@
-const OPTIONS = getJSONScript("progress_options");
-
-const STYLE_CLASSES = {
-  10: "info",
-  20: "info",
-  25: "success",
-  30: "warning",
-  40: "error",
-};
-
-const ICONS = {
-  10: "mdi:information",
-  20: "mdi:information",
-  25: "mdi:check-circle",
-  30: "mdi:alert-outline",
-  40: "mdi:alert-octagon-outline",
-};
-
-function setProgress(progress) {
-  $("#progress-bar").css("width", progress + "%");
-}
-
-function renderMessageBox(level, text) {
-  return (
-    '<div class="alert ' +
-    STYLE_CLASSES[level] +
-    '"><p><i class="material-icons iconify left" data-icon="' +
-    ICONS[level] +
-    '"></i>' +
-    text +
-    "</p></div>"
-  );
-}
-
-function updateMessages(messages) {
-  const messagesBox = $("#messages");
-
-  // Clear container
-  messagesBox.html("");
-
-  // Render message boxes
-  $.each(messages, function (i, message) {
-    messagesBox.append(renderMessageBox(message[0], message[1]));
-  });
-}
-
-function customProgress(
-  progressBarElement,
-  progressBarMessageElement,
-  progress
-) {
-  setProgress(progress.percent);
-
-  if (progress.hasOwnProperty("messages")) {
-    updateMessages(progress.messages);
-  }
-}
-
-function customSuccess(progressBarElement, progressBarMessageElement, result) {
-  setProgress(100);
-  if (result) {
-    updateMessages(result);
-  }
-  $("#result-alert").addClass("success");
-  $("#result-icon").attr("data-icon", "mdi:check-circle-outline");
-  $("#result-text").text(OPTIONS.success);
-  $("#result-box").show();
-  $("#result-button").show();
-  const redirect =
-    "redirect_on_success" in OPTIONS && OPTIONS.redirect_on_success;
-  if (redirect) {
-    window.location.replace(OPTIONS.redirect_on_success);
-  }
-}
-
-function customError(
-  progressBarElement,
-  progressBarMessageElement,
-  excMessage
-) {
-  setProgress(100);
-  if (excMessage) {
-    updateMessages([40, excMessage]);
-  }
-  $("#result-alert").addClass("error");
-  $("#result-icon").attr("data-icon", "mdi:alert-octagon-outline");
-  $("#result-text").text(OPTIONS.error);
-  $("#result-box").show();
-}
-
-$(document).ready(function () {
-  $("#progress-bar").removeClass("indeterminate").addClass("determinate");
-
-  var progressUrl = Urls["taskStatus"](OPTIONS.task_id);
-  CeleryProgressBar.initProgressBar(progressUrl, {
-    onProgress: customProgress,
-    onSuccess: customSuccess,
-    onError: customError,
-  });
-});
diff --git a/aleksis/core/static/js/search.js b/aleksis/core/static/js/search.js
index 1841829ecca8de19f51aa154732e90d309ec9aa4..c24c79d747227ac71aa75901e8891da30956e7af 100644
--- a/aleksis/core/static/js/search.js
+++ b/aleksis/core/static/js/search.js
@@ -7,7 +7,7 @@
 
 var Autocomplete = function (options) {
   this.form_selector = options.form_selector || ".autocomplete";
-  this.url = options.url || Urls.searchbarSnippets();
+  this.url = options.url;
   this.delay = parseInt(options.delay || 300);
   this.minimum_length = parseInt(options.minimum_length || 3);
   this.form_elem = null;
diff --git a/aleksis/core/tasks.py b/aleksis/core/tasks.py
index 7b7e529b3a1fa21c5a6620170296d9ee1eb777ec..8306203ee66228ef264b7d06f15e6eb46690b5ab 100644
--- a/aleksis/core/tasks.py
+++ b/aleksis/core/tasks.py
@@ -1,3 +1,4 @@
+import time
 from datetime import timedelta
 
 from django.conf import settings
@@ -55,3 +56,16 @@ def clear_oauth_tokens():
 def send_notifications():
     """Send due notifications to users."""
     _send_due_notifications()
+
+
+@app.task
+def send_notification_for_done_task(task_id):
+    """Send a notification for a done task."""
+    from aleksis.core.models import TaskUserAssignment
+
+    # Wait five seconds to ensure that the client has received the final status
+    time.sleep(5)
+
+    assignment = TaskUserAssignment.objects.get(task_result__task_id=task_id)
+    if not assignment.result_fetched:
+        assignment.create_notification()
diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html
index 3158986ac5ed921244b43663e64c2fe9d728cd32..582a709d2f3c0a6935ebb218de7ae117336fa7bc 100644
--- a/aleksis/core/templates/core/base.html
+++ b/aleksis/core/templates/core/base.html
@@ -239,6 +239,8 @@
 {% include_js "materialize" %}
 {% include_js "sortablejs" %}
 {% include_js "jquery-sortablejs" %}
+{% url "searchbar_snippets" as search_snippets_url %}
+{{ search_snippets_url|json_script:"search-snippet-url" }}
 <script type="text/javascript" src="{% static 'js/search.js' %}"></script>
 <script type="text/javascript" src="{% static 'js/main.js' %}"></script>
 </body>
diff --git a/aleksis/core/templates/core/pages/progress.html b/aleksis/core/templates/core/pages/progress.html
index dc12869c8130a26bf2d5d96f1d30e8a83444bd61..82dc6506058e0b0e7da785a0161c1f45585634b7 100644
--- a/aleksis/core/templates/core/pages/progress.html
+++ b/aleksis/core/templates/core/pages/progress.html
@@ -1,63 +1,10 @@
-{% extends "core/base.html" %}
+{% extends "core/vue_base.html" %}
 {% load i18n static %}
 
 {% block browser_title %}
-  {{ title }}
-{% endblock %}
-{% block page_title %}
-  {{ title }}
+  {% trans "Progress" %}
 {% endblock %}
 
 {% block content %}
-
-  <div class="container">
-    <div class="row">
-      <div class="progress center">
-        <div class="indeterminate" style="width: 0;" id="progress-bar"></div>
-      </div>
-      <h6 class="center">
-        {{ progress.title }}
-      </h6>
-    </div>
-    <div class="row">
-      <noscript>
-        <div class="alert warning">
-          <p>
-            <i class="material-icons iconify left" data-icon="mdi:alert-outline"></i>
-            {% blocktrans %}
-              Without activated JavaScript the progress status can't be updated.
-            {% endblocktrans %}
-          </p>
-        </div>
-      </noscript>
-
-      <div id="messages"></div>
-
-      <div id="result-box" style="display: none;">
-        <div class="alert" id="result-alert">
-          <div>
-            <i class="material-icons iconify left" id="result-icon" data-icon="mdi:check-circle-outline"></i>
-            <p id="result-text"></p>
-          </div>
-        </div>
-
-        {% url "index" as index_url %}
-        <a class="btn waves-effect waves-light" href="{{ back_url|default:index_url }}">
-          <i class="material-icons iconify left" data-icon="mdi:arrow-left"></i>
-          {% trans "Go back" %}
-        </a>
-        {% if additional_button %}
-          <a class="btn waves-effect waves-light" href="{{ additional_button.href }}" id="result-button" style="display: none;">
-            <i class="material-icons iconify left" data-icon="{{ additional_button.icon|default:"" }}"></i>
-            {{ additional_button.caption }}
-          </a>
-        {% endif %}
-      </div>
-    </div>
-  </div>
-
-  {{ progress|json_script:"progress_options" }}
-  <script src="{% static "js/helper.js" %}"></script>
-  <script src="{% static "celery_progress/celery_progress.js" %}"></script>
-  <script src="{% static "js/progress.js" %}"></script>
+  <router-view></router-view>
 {% endblock %}
diff --git a/aleksis/core/templates/core/vue_base.html b/aleksis/core/templates/core/vue_base.html
index 07b06277856d27283a8612882c0d287f40590f1f..85abd8e79d4752aa79e771c5d251dfb1fe450f53 100644
--- a/aleksis/core/templates/core/vue_base.html
+++ b/aleksis/core/templates/core/vue_base.html
@@ -74,7 +74,7 @@
           {% has_perm 'core.search_rule' user as search %}
           {% if search %}
             <v-list-item class="search">
-              <sidenav-search action="{% url "haystack_search" %}" placeholder="{% trans "Search" %}"></sidenav-search>
+              <sidenav-search action="{% url "haystack_search" %}"></sidenav-search>
             </v-list-item>
           {% endif %}
           {% include "core/partials/vue_sidenav.html" %}
@@ -160,6 +160,8 @@
       </v-container>
     </v-main>
 
+    <celery-progress-bottom />
+
     <v-footer app absolute inset dark class="pa-0 d-flex" color="primary lighten-1">
       <v-card
         flat
diff --git a/aleksis/core/templates/search/searchbar_snippet.html b/aleksis/core/templates/search/searchbar_snippet.html
index d2a401c4f874d6a2c2a71c5b122eb3879cd83d57..d0ec278c2b232f701e6231e337c14c10c0096839 100644
--- a/aleksis/core/templates/search/searchbar_snippet.html
+++ b/aleksis/core/templates/search/searchbar_snippet.html
@@ -1,4 +1,4 @@
 <a href="{{ result.object.get_absolute_url|default:"#" }}" class="collection-item search-item">
   {{ result.object }}
-  <i class="material-icons secondary-content search-result-icon" data-icon="mdi:{{ result.object.icon_ }}"></i>
+  <i class="material-icons secondary-content search-result-icon iconify" data-icon="mdi:{{ result.object.icon_ }}"></i>
 </a>
diff --git a/aleksis/core/util/celery_progress.py b/aleksis/core/util/celery_progress.py
index 91b5b7168f215da509c71806f137483a422872d3..d377d9d334c0243af210cc68e3474aeaea748b2e 100644
--- a/aleksis/core/util/celery_progress.py
+++ b/aleksis/core/util/celery_progress.py
@@ -5,12 +5,13 @@ from typing import Callable, Generator, Iterable, Optional, Sequence, Union
 from django.apps import apps
 from django.contrib import messages
 from django.http import HttpRequest
-from django.shortcuts import render
+from django.shortcuts import redirect
 
 from celery.result import AsyncResult
 from celery_progress.backend import PROGRESS_STATE, AbstractProgressRecorder
 
 from ..celery import app
+from ..tasks import send_notification_for_done_task
 
 
 class ProgressRecorder(AbstractProgressRecorder):
@@ -156,6 +157,11 @@ def recorded_task(orig: Optional[Callable] = None, **kwargs) -> Union[Callable,
         def _inject_recorder(task, *args, **kwargs):
             recorder = ProgressRecorder(task)
             orig(*args, **kwargs, recorder=recorder)
+
+            # Start notification task to ensure
+            # that the user is informed about the result in any case
+            send_notification_for_done_task.delay(task.request.id)
+
             return recorder._messages
 
         # Force bind to True because _inject_recorder needs the Task object
@@ -203,22 +209,15 @@ def render_progress_page(
     TaskUserAssignment = apps.get_model("core", "TaskUserAssignment")
     assignment = TaskUserAssignment.create_for_task_id(task_result.task_id, request.user)
 
-    # Prepare context for progress page
-    context["title"] = title
-    context["back_url"] = back_url
-    context["progress"] = {
-        "task_id": task_result.task_id,
-        "title": progress_title,
-        "success": success_message,
-        "error": error_message,
-        "redirect_on_success": redirect_on_success_url,
-    }
-
-    if button_url and button_title:
-        context["additional_button"] = {
-            "href": button_url,
-            "caption": button_title,
-            "icon": button_icon,
-        }
-
-    return render(request, "core/pages/progress.html", context)
+    assignment.title = title
+    assignment.back_url = back_url or ""
+    assignment.progress_title = progress_title or ""
+    assignment.error_message = error_message or ""
+    assignment.success_message = success_message or ""
+    assignment.redirect_on_success_url = redirect_on_success_url or ""
+    assignment.additional_button_title = button_title or ""
+    assignment.additional_button_url = button_url or ""
+    assignment.additional_button_icon = button_icon or ""
+    assignment.save()
+
+    return redirect("task_status", task_id=task_result.task_id)
diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py
index c840c620fd102dc01b3300f2205e19e399b00ed2..66b5e1a9fdf58d4630651e1795167df001f42567 100644
--- a/aleksis/core/util/pdf.py
+++ b/aleksis/core/util/pdf.py
@@ -154,7 +154,7 @@ def render_pdf(
         back_url=context.get("back_url", reverse("index")),
         button_title=_("Download PDF"),
         button_url=redirect_url,
-        button_icon="picture_as_pdf",
+        button_icon="mdi-file-pdf-box",
     )
 
 
diff --git a/aleksis/core/util/predicates.py b/aleksis/core/util/predicates.py
index 5ba4271c08a4244ba5f359e9c46e01eff6c6d112..9996fa4655c75d525d7d6a305d3f1fc839323889 100644
--- a/aleksis/core/util/predicates.py
+++ b/aleksis/core/util/predicates.py
@@ -160,3 +160,9 @@ def has_activated_2fa(user: User) -> bool:
 def is_assigned_to_current_person(user: User, obj: Model) -> bool:
     """Check if the object is assigned to the current person."""
     return getattr(obj, "person", None) == user.person
+
+
+@predicate
+def is_own_celery_task(user: User, obj: Model) -> bool:
+    """Check if the celery task is owned by the current user."""
+    return obj.user == user
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 6512782b6e970bde7a236bd480359883e27cdb7e..e55a58b9c88e62ba3c9716300e8416e86e6d6eec 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -40,7 +40,6 @@ from allauth.account.utils import has_verified_email, send_email_confirmation
 from allauth.account.views import PasswordChangeView, PasswordResetView, SignupView
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialAccount
-from celery_progress.views import get_progress
 from django_celery_results.models import TaskResult
 from django_filters.views import FilterView
 from django_tables2 import RequestConfig, SingleTableMixin, SingleTableView
@@ -1338,17 +1337,15 @@ class RedirectToPDFFile(SingleObjectMixin, View):
         return redirect(file_object.file.url)
 
 
-class CeleryProgressView(View):
+class CeleryProgressView(PermissionRequiredMixin, DetailView):
     """Wrap celery-progress view to check permissions before."""
 
-    def get(self, request: HttpRequest, task_id: str, *args, **kwargs) -> HttpResponse:
-        if request.user.is_anonymous:
-            raise Http404(_("The requested task does not exist or is not accessible"))
-        if not TaskUserAssignment.objects.filter(
-            task_result__task_id=task_id, user=request.user
-        ).exists():
-            raise Http404(_("The requested task does not exist or is not accessible"))
-        return get_progress(request, task_id, *args, **kwargs)
+    template_name = "core/pages/progress.html"
+    permission_required = "core.view_progress_rule"
+
+    def get_object(self, queryset=None):
+        task_id = self.kwargs.get("task_id")
+        return TaskUserAssignment.objects.get(task_result__task_id=task_id)
 
 
 class CustomPasswordChangeView(LoginRequiredMixin, PermissionRequiredMixin, PasswordChangeView):