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):