diff --git a/aleksis/core/assets/components/CeleryProgress.vue b/aleksis/core/assets/components/CeleryProgress.vue new file mode 100644 index 0000000000000000000000000000000000000000..48c35370e5efb6ca6213bbcea6d8445eac01a850 --- /dev/null +++ b/aleksis/core/assets/components/CeleryProgress.vue @@ -0,0 +1,127 @@ +<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 }} + </div> + <v-alert + v-if="data" + v-for="(message, idx) in progress.messages" + dense + text + :type="STYLE_CLASSES[message.level]" + transition="slide-x-transition" + :key="idx" + > + {{ message.message }} + </v-alert> + <v-alert + v-if="progress.state === 'ERROR'" + dense + text + type="error" + transition="slide-x-transition" + > + {{ progress.meta.errorMessage }} + </v-alert> + <v-alert + v-if="progress.state === 'SUCCESS'" + dense + text + type="success" + transition="slide-x-transition" + > + {{ progress.meta.successMessage }} + </v-alert> + </v-card-text> + <v-card-actions + v-if="progress && (progress.state === 'ERROR' || progress.state === 'SUCCESS')"> + <v-btn :href="progress.meta.backUrl" text color="secondary"> + <v-icon left>mdi-arrow-left</v-icon> + Go back + </v-btn> + <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> +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', +}; +export default { + name: 'CeleryProgress', + apollo: { + celeryProgressByTaskId: { + query: require('./celeryProgress.graphql'), + variables() { + return { + taskId: this.$route.params.taskId, + }; + }, + pollInterval: 1000, + } + }, + data() { + return { + STYLE_CLASSES, + ICONS, + }; + }, + computed: { + progress() { + return this.celeryProgressByTaskId; + }, + state() { + return this.progress ? this.progress.state : null; + }, + }, + watch: { + state(newState, oldState) + { + if (newState === 'SUCCESS' || newState === 'ERROR') { + this.$apollo.queries.celeryProgressByTaskId.stopPolling(); + } + if (newState === 'SUCCESS') { + window.location.replace(this.progress.meta.redirectOnSuccessUrl); + // FIXME this.$router.push(this.progress.meta.redirectOnSuccessUrl); + } + } + } +} +</script> diff --git a/aleksis/core/assets/components/celeryProgress.graphql b/aleksis/core/assets/components/celeryProgress.graphql new file mode 100644 index 0000000000000000000000000000000000000000..49b03ef82bd13e72248d72a98abf90fe6e7c8f1f --- /dev/null +++ b/aleksis/core/assets/components/celeryProgress.graphql @@ -0,0 +1,29 @@ +query($taskId: String!){ + celeryProgressByTaskId(taskId:$taskId) { + state + success + progress { + current + total + percent + } + complete + messages { + level + message + } + meta { + title + progressTitle + errorMessage + successMessage + redirectOnSuccessUrl + backUrl + additionalButton { + title + icon + url + } + } + } +} diff --git a/aleksis/core/assets/index.js b/aleksis/core/assets/index.js index 94f4131baf7181ea2a299c518f5fe942b850926d..9d2837ee974ce2b0d2becdf3cdfeffc05448f969 100644 --- a/aleksis/core/assets/index.js +++ b/aleksis/core/assets/index.js @@ -2,3 +2,6 @@ import '@mdi/font/css/materialdesignicons.css' import "./util" import "./app" +import CeleryProgress from "./components/CeleryProgress.vue"; + +window.router.addRoute({ path: "/celery_progress/:taskId", component: CeleryProgress, props: true }); 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/models.py b/aleksis/core/models.py index 2f603ae0d2e2813b50f2024056cb460d71b4e484..3462c40349f7d2130b8d59f812e174944b50b1e8 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 @@ -1257,6 +1258,20 @@ 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 + ) + @classmethod def create_for_task_id(cls, task_id: str, user: "User") -> "TaskUserAssignment": # Use get_or_create to ensure the TaskResult exists @@ -1265,6 +1280,11 @@ 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() + 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.py b/aleksis/core/schema.py index cb6325c0640557314ae299a1d81719d910468391..5fed3cfdb08e902f38e806361c3203ec73434cf2 100644 --- a/aleksis/core/schema.py +++ b/aleksis/core/schema.py @@ -7,7 +7,7 @@ from graphene_django import DjangoObjectType from graphene_django.forms.mutation import DjangoModelFormMutation from .forms import PersonForm -from .models import Group, Notification, Person +from .models import Group, Notification, Person, TaskUserAssignment from .util.core_helpers import get_app_module, get_app_packages, has_person from .util.frontend_helpers import get_language_cookie @@ -55,6 +55,68 @@ class SystemPropertiesType(graphene.ObjectType): ] +class CeleryProgressMessage(ObjectType): + message = graphene.String(required=True) + level = graphene.Int(required=True) + + def resolve_message(root, info, **kwargs): + return root[1] + + def resolve_level(root, info, **kwargs): + return root[0] + + +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) + + 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 PersonMutation(DjangoModelFormMutation): person = graphene.Field(PersonType) @@ -89,6 +151,8 @@ class Query(graphene.ObjectType): system_properties = graphene.Field(SystemPropertiesType) + celery_progress_by_task_id = graphene.Field(CeleryProgressType, task_id=graphene.String()) + def resolve_notifications(root, info, **kwargs): # FIXME do permission stuff return Notification.objects.all() @@ -109,6 +173,18 @@ class Query(graphene.ObjectType): def resolve_system_properties(root, info, **kwargs): return True + def resolve_celery_progress_by_task_id(root, info, task_id, **kwargs): + print(TaskUserAssignment.objects.filter(task_result__task_id=task_id), "HDS") + print("DOES DO ") + print("RUN") + 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() + progress["meta"] = task + return progress + class Mutation(graphene.ObjectType): update_person = PersonMutation.Field() diff --git a/aleksis/core/static/js/progress.js b/aleksis/core/static/js/progress.js deleted file mode 100644 index 8a97577e93d276cd62c69ead4c65b448a7631d49..0000000000000000000000000000000000000000 --- a/aleksis/core/static/js/progress.js +++ /dev/null @@ -1,84 +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/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/util/celery_progress.py b/aleksis/core/util/celery_progress.py index 91b5b7168f215da509c71806f137483a422872d3..c0d271d1d2896d639052e0f54d922918d14b457f 100644 --- a/aleksis/core/util/celery_progress.py +++ b/aleksis/core/util/celery_progress.py @@ -5,7 +5,7 @@ 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 @@ -203,22 +203,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 8721d8c3167ba8a01fd250a0967f56af495bdf78..24d360394b03298c2eb88c244f13e079220a33c0 100644 --- a/aleksis/core/util/pdf.py +++ b/aleksis/core/util/pdf.py @@ -147,7 +147,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):